diff --git a/.env.example b/.env.example index a088e1f4..c37acc14 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,12 @@ API_POLLING_RATE_LIMIT=300 # trailing slash. When unset, enrollment falls back to the request Host. SENCHO_PUBLIC_URL= +# Comma-separated CIDRs of reverse proxies trusted to set forwarding headers. +# Set a single IPv4 proxy as a /32, a single IPv6 proxy as a /128, or use the +# proxy network CIDR when Sencho is behind one (for example, 192.168.1.50/32). +# Unset or invalid values make Sencho ignore forwarded client and scheme data. +SENCHO_TRUSTED_PROXY_CIDRS= + # ─── Pilot agent (remote host only) ────────────────────────────── # These three vars are required ONLY on a remote host running as a # pilot-agent reverse-tunnel container. The primary instance does not @@ -53,11 +59,6 @@ SENCHO_PUBLIC_URL= # this unset. SENCHO_MODE= -# Comma-separated CIDRs of reverse proxies trusted to set X-Forwarded-Proto -# for Pilot Agent TLS termination. Unset or invalid: non-TLS Pilot upgrades are -# treated as non-confidential and hub registry credential delivery is skipped. -SENCHO_TRUSTED_PROXY_CIDRS= - # WebSocket-capable URL of the controlling Sencho instance. Use https:// # scheme; the agent rewrites it to wss:// for the tunnel upgrade. SENCHO_PRIMARY_URL= diff --git a/.github/actions/start-app/action.yml b/.github/actions/start-app/action.yml index c5c82c1a..6918f2f1 100644 --- a/.github/actions/start-app/action.yml +++ b/.github/actions/start-app/action.yml @@ -77,6 +77,7 @@ runs: COMPOSE_DIR: ${{ inputs.compose-dir }} PORT: ${{ inputs.port }} NODE_ENV: test + SENCHO_E2E_ALLOW_LOOPBACK_OUTBOUND: 'true' - name: Start frontend dev server shell: bash diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 9b7502a3..60b7c0c2 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -20,6 +20,31 @@ paths-ignore: # path scoping is ignored for that query, so the dedicated sink module is # excluded from JS analysis instead (see codeql-config comment on e2e above). - backend/src/services/git/sshCredentialFiles.ts + # redirectPreflight walks an HTTPS redirect chain to decide whether git may + # be re-run against the destination, so the operator's configured repository + # URL reaches an outbound https.get and CodeQL flags request forgery. A + # barrier model on the module's own origin-check function (declaring its + # return value clean, the same mechanism sanitizeForLog uses for + # log-injection) was tried first and did not clear this alert: the + # js/request-forgery query does not consult the general dataflow + # barrierModel the way log-injection does. Excluded from JS analysis + # instead, for the same reason as the two sinks below: every URL this + # module requests, including the first, is checked against the configured + # repository's origin (scheme, host, and port) before being requested, so + # the walk cannot reach a host the operator did not configure, and the + # probe carries no credentials. Sencho is single-tenant and self-hosted: + # the admin who sets the repository URL owns the server, the trust model + # already accepted for registry-api.ts and NotificationService.ts. + - backend/src/services/git/redirectPreflight.ts + # Per-fetch HTTPS CA bundle sink: combines the operator-supplied per-source + # PEM, the optional NODE_EXTRA_CA_CERTS file, and (on Windows) Git's + # bundled system bundle into one mode-0600 file the git child reads via + # http.sslCAInfo. Every input to the write has already been validated as + # PEM by the caller or comes from a known, operator-controlled path on the + # same host; the file lives under the per-fetch workspace, which the caller + # deletes in a finally block. Excluded from JS analysis for the same reason + # as the SSH sink above. + - backend/src/services/git/gitCaBundleSink.ts query-filters: # API tokens are 256-bit CSPRNG random; sha256 of the raw token is the diff --git a/.github/workflows/catalog-drift.yml b/.github/workflows/catalog-drift.yml new file mode 100644 index 00000000..a402669b --- /dev/null +++ b/.github/workflows/catalog-drift.yml @@ -0,0 +1,61 @@ +name: Catalog Drift Check + +on: + pull_request: + branches: [main] + paths: + - 'docs/feature-catalog.yaml' + - 'scripts/website-catalog/**' + - '.github/workflows/catalog-drift.yml' + push: + branches: [main] + paths: + - 'docs/feature-catalog.yaml' + - 'scripts/website-catalog/**' + # Drift can also be introduced from the website side, which changes nothing + # in this repository and so triggers none of the filters above. Check daily + # so an edited or reverted snapshot cannot sit undetected. + schedule: + - cron: '17 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + # Mint a read-only installation token scoped to the website repository + # only. The default GITHUB_TOKEN cannot read Studio-Saelix/sencho-website. + - name: Generate GitHub App installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: sencho-website + permission-contents: read + + # The root checkout must come first. actions/checkout cleans its + # destination, so running it after the nested one deletes website-checkout. + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/sencho-website + token: ${{ steps.app-token.outputs.token }} + path: website-checkout + - name: Install root dependencies + run: npm ci + - name: Canonical validation + run: node scripts/website-catalog/canonical-validate.mjs + # Compare the canonical catalog against the snapshot the website has + # actually committed. Do not run sync-feature-catalog here: it rewrites + # that snapshot from the canonical file, so the comparison would only + # ever read back what it just wrote and could never report drift. + - name: Compare against the committed website snapshot + run: node scripts/website-catalog/check-website-drift.mjs --website-dir website-checkout + - name: Verify no internal identifiers in canonical file + run: node scripts/website-catalog/test-catalog-no-leak.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a39c0445..424f99d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,7 +127,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 # Computed once per job run. Feeds Dockerfile's APK_CACHE_BUST arg so # the `apk upgrade` layer rebuilds at least once per calendar day, even diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ed282923..6392dc04 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,16 +33,16 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} queries: security-extended config-file: .github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: /language:${{ matrix.language }} diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index f92848f9..96de6946 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -61,7 +61,7 @@ jobs: platforms: arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 # Feeds the Dockerfile's APK_CACHE_BUST arg so the `apk upgrade` layer # rebuilds at least once per calendar day even when every other input is diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 18a7e8da..e126e7d9 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -82,7 +82,7 @@ jobs: platforms: arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - name: Compute daily apk cache bust value id: apk-bust diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 762a22b9..13e3cfab 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -49,7 +49,7 @@ jobs: platforms: arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 # Computed once per job run. Feeds Dockerfile's APK_CACHE_BUST arg so # the `apk upgrade` layer rebuilds at least once per calendar day, even diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 03717551..d2c48b3f 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -50,7 +50,7 @@ jobs: # scan-main job below keeps the two result sets distinct in the UI. - name: Upload SARIF to code scanning if: always() - uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: trivy-published.sarif category: trivy-published-image @@ -67,7 +67,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 # Mirrors the daily-cache-bust logic from ci.yml / docker-publish.yml so # the apk upgrade layer rebuilds at least once per calendar day. @@ -98,7 +98,7 @@ jobs: - name: Upload SARIF to code scanning if: always() - uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: trivy-main.sarif category: trivy-main-head diff --git a/Dockerfile b/Dockerfile index 0e6b266d..65d8bd9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,32 +93,37 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \ # Stage 4a: Build Docker CLI from source against Go 1.26.3 # # CLI v29.4.1 ships otel/sdk v1.43.0, resolving CVE-2026-39883 (BSD kenv) and -# CVE-2026-39882 (OTLP response OOM). It also carries the CVE-2025-15558 fix -# (Windows plugin search path LPE, fixed since v29.2.0). Building from source -# with Go 1.26.3 additionally eliminates Go stdlib CVEs present in the upstream -# static binary. +# CVE-2026-39882 (OTLP response OOM). Building against grpc v1.83.2 below +# transitively resolves otel to v1.44.0, which additionally clears +# CVE-2026-41178 (baggage header parsing dropped its raw-length cap, allowing +# resource exhaustion via an oversized header, present through v1.43.0). It +# also carries the CVE-2025-15558 fix (Windows plugin search path LPE, fixed +# since v29.2.0). Building from source with Go 1.26.3 additionally eliminates +# Go stdlib CVEs present in the upstream static binary. # -# Runs on the BUILD platform; GOARCH cross-compiles the static binary for TARGET. -# The fetch pulls only the v29.4.1 commit, minimising transfer size. -# docker/cli uses CalVer and ships vendor.mod instead of go.mod to avoid SemVer -# compliance requirements. We copy vendor.mod -> go.mod, drop the committed vendor -# tree, bump golang.org/x/net to v0.56.0, golang.org/x/text to v0.39.0, -# google.golang.org/grpc to v1.82.1, and github.com/moby/go-archive to v0.3.0, -# and build with -mod=mod so the patched modules are resolved from the module -# proxy. x/net v0.53.0 is flagged for six +# Runs on the BUILD platform; GOARCH cross-compiles the static binary for +# TARGET. The fetch pulls only the v29.4.1 commit, minimising transfer size. +# docker/cli uses CalVer and ships vendor.mod instead of go.mod to avoid +# SemVer compliance requirements. We copy vendor.mod -> go.mod, drop the +# committed vendor tree, bump golang.org/x/net to v0.58.0, golang.org/x/text +# to v0.41.0, google.golang.org/grpc to v1.83.2, and +# github.com/moby/go-archive to v0.3.0, and build with -mod=mod so the patched +# modules are resolved from the module proxy. x/net v0.53.0 is flagged for six # HIGH advisories (CVE-2026-25680, -25681, -27136, -39821, -42502, -42506; # x/net/html parsing and x/net/idna). x/net v0.55.0 is flagged for # CVE-2026-46600 (dnsmessage denial of service). x/text v0.37.0 is flagged for # CVE-2026-56852 (norm.Iter infinite loop on crafted input). grpc v1.80.0 is -# flagged for GHSA-hrxh-6v49-42gf (xDS RBAC / HTTP/2). go-archive v0.2.0 is -# flagged for CVE-2026-17106 (HIGH), where a crafted tar archive can write -# outside the extraction directory. Removing vendor/ keeps -# -mod=mod from reading the stale copy, and avoids `go mod tidy` (which does -# not run cleanly against docker/cli's vendor.mod manifest). This stage now -# fetches modules at build time rather than building fully offline. +# flagged for GHSA-hrxh-6v49-42gf (xDS RBAC / HTTP/2), and grpc v1.82.1 for +# CVE-2026-84304 (unauthenticated peer OOM via fragmented HTTP/2 DATA frames +# buffered per-message). go-archive v0.2.0 is flagged for CVE-2026-17106 +# (HIGH), where a crafted tar archive can write outside the extraction +# directory. Removing vendor/ keeps -mod=mod from reading the stale copy, and +# avoids `go mod tidy` (which does not run cleanly against docker/cli's +# vendor.mod manifest). This stage now fetches modules at build time rather +# than building fully offline. # Base image pinned by digest so the Go toolchain that compiles the static # Docker CLI binary cannot change without an explicit Dependabot bump. -FROM --platform=$BUILDPLATFORM golang:1.27rc3-alpine@sha256:c5aca77a4d16cb6688dbf3ccade67eff6f05ee208bc854d060e6947f5c27e23c AS cli-builder +FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS cli-builder ARG TARGETARCH @@ -139,9 +144,9 @@ RUN mkdir -p /build RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \ rm -rf vendor && \ - go get golang.org/x/net@v0.56.0 \ - golang.org/x/text@v0.39.0 \ - google.golang.org/grpc@v1.82.1 \ + go get golang.org/x/net@v0.58.0 \ + golang.org/x/text@v0.41.0 \ + google.golang.org/grpc@v1.83.2 \ github.com/moby/go-archive@v0.3.0 && \ CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ -mod=mod \ @@ -165,12 +170,15 @@ RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \ # toolchain eliminates Go stdlib CVEs from the binary's SBOM. # # Compose v5.1.3 still bundles otel/sdk v1.42.0 transitively via buildkit -# v0.29.0. The go get step below bumps otel to v1.43.0 to resolve -# CVE-2026-39883 (BSD kenv) and CVE-2026-39882 (OTLP response OOM) so that -# the compose binary scans completely clean. It also bumps -# github.com/moby/go-archive to v0.3.0 for CVE-2026-17106 (HIGH), where a -# crafted tar archive can write outside the extraction directory; compose -# pulls the same archive code in transitively through buildkit. +# v0.29.0. The go get step below bumps otel to v1.44.0 to resolve +# CVE-2026-39883 (BSD kenv), CVE-2026-39882 (OTLP response OOM), and +# CVE-2026-41178 (baggage header parsing dropped its raw-length cap, allowing +# resource exhaustion via an oversized header, present through v1.43.0) so +# that the compose binary scans completely clean; v1.44.0 is also grpc +# v1.83.2's minimum below. It also bumps github.com/moby/go-archive to v0.3.0 +# for CVE-2026-17106 (HIGH), where a crafted tar archive can write outside the +# extraction directory; compose pulls the same archive code in transitively +# through buildkit. # # Compose v5.1.3 also pins github.com/containerd/containerd/v2 v2.2.3, which # carries CVE-2026-46680 (runAsNonRoot evasion in containerd's runtime @@ -182,14 +190,18 @@ RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \ # daemon-side (containerd's CRI service) and is not reached by compose at all, # so this is defense-in-depth rather than a live exposure. # -# The same go get also bumps google.golang.org/grpc from v1.80.0 to v1.82.1 to -# clear GHSA-hrxh-6v49-42gf (xDS RBAC fail-open and HTTP/2 transport issues), -# golang.org/x/text from v0.38.0 to v0.39.0 to clear CVE-2026-56852 -# (norm.Iter infinite loop on crafted input), and golang.org/x/net from -# v0.55.0 to v0.56.0 to clear CVE-2026-46600 (dnsmessage denial of service). +# The same go get also bumps google.golang.org/grpc from v1.80.0 to v1.83.2 to +# clear GHSA-hrxh-6v49-42gf (xDS RBAC fail-open and HTTP/2 transport issues) +# and CVE-2026-84304 (unauthenticated peer OOM via fragmented HTTP/2 DATA +# frames buffered per-message), golang.org/x/text from v0.38.0 to v0.41.0 to +# clear CVE-2026-56852 (norm.Iter infinite loop on crafted input) and satisfy +# x/crypto's minimum, golang.org/x/net from v0.55.0 to v0.58.0 to clear +# CVE-2026-46600 (dnsmessage denial of service) and satisfy grpc v1.83.2's +# minimum version, and golang.org/x/crypto from v0.53.0 to v0.55.0 to clear +# CVE-2026-56854 (SSH host-key verification bypass). # Base image pinned by digest (same image as cli-builder above) so both # source builds share an identical, immutable Go toolchain. -FROM --platform=$BUILDPLATFORM golang:1.27rc3-alpine@sha256:c5aca77a4d16cb6688dbf3ccade67eff6f05ee208bc854d060e6947f5c27e23c AS compose-builder +FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS compose-builder ARG TARGETARCH @@ -208,28 +220,32 @@ WORKDIR /src/docker-compose RUN mkdir -p /build -# Patch otel/sdk and exporters from v1.42.0 → v1.43.0 to clear CVE-2026-39883 -# and CVE-2026-39882, bump containerd/v2 from v2.2.3 → v2.2.5 to clear -# CVE-2026-46680 plus the CVE-2026-53488 / 53489 / 53492 cluster, bump -# google.golang.org/grpc to v1.82.1 to clear GHSA-hrxh-6v49-42gf, and bump -# golang.org/x/net to v0.56.0 to clear CVE-2026-46600. The containerd bump is -# patch-level; the otel, grpc, and x/net bumps are minor security releases. -# None introduce breaking API changes. +# Patch otel/sdk and exporters from v1.42.0 → v1.44.0 to clear CVE-2026-39883, +# CVE-2026-39882, and CVE-2026-41178 (present through v1.43.0; v1.44.0 is also +# grpc v1.83.2's minimum below), bump containerd/v2 from v2.2.3 → v2.2.5 to +# clear CVE-2026-46680 plus the CVE-2026-53488 / 53489 / 53492 cluster, bump +# google.golang.org/grpc to v1.83.2 to clear GHSA-hrxh-6v49-42gf, +# CVE-2026-84304, and CVE-2026-84445, bump golang.org/x/net to v0.58.0 to clear +# CVE-2026-46600, +# and bump golang.org/x/crypto to v0.55.0 to clear CVE-2026-56854. The +# containerd bump is patch-level; the otel, grpc, x/net, and x/crypto bumps +# are minor security releases. None introduce breaking API changes. RUN --mount=type=cache,id=go-mod,sharing=locked,target=/go/pkg/mod \ - go get go.opentelemetry.io/otel@v1.43.0 \ - go.opentelemetry.io/otel/sdk@v1.43.0 \ - go.opentelemetry.io/otel/sdk/metric@v1.43.0 \ - go.opentelemetry.io/otel/metric@v1.43.0 \ - go.opentelemetry.io/otel/trace@v1.43.0 \ - go.opentelemetry.io/otel/exporters/otlp/otlptrace@v1.43.0 \ - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.43.0 \ - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0 \ - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@v1.43.0 \ - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0 \ + go get go.opentelemetry.io/otel@v1.44.0 \ + go.opentelemetry.io/otel/sdk@v1.44.0 \ + go.opentelemetry.io/otel/sdk/metric@v1.44.0 \ + go.opentelemetry.io/otel/metric@v1.44.0 \ + go.opentelemetry.io/otel/trace@v1.44.0 \ + go.opentelemetry.io/otel/exporters/otlp/otlptrace@v1.44.0 \ + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.44.0 \ + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.44.0 \ + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@v1.44.0 \ + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.44.0 \ github.com/containerd/containerd/v2@v2.2.5 \ - google.golang.org/grpc@v1.82.1 \ - golang.org/x/text@v0.39.0 \ - golang.org/x/net@v0.56.0 \ + google.golang.org/grpc@v1.83.2 \ + golang.org/x/text@v0.41.0 \ + golang.org/x/net@v0.58.0 \ + golang.org/x/crypto@v0.55.0 \ github.com/moby/go-archive@v0.3.0 && \ go mod tidy @@ -282,7 +298,7 @@ ARG APK_CACHE_BUST=unset # removing it also eliminates CVE-2026-33671 (picomatch ReDoS in npm). RUN echo "apk cache bust: ${APK_CACHE_BUST}" && \ apk upgrade --no-cache && \ - apk add --no-cache bash su-exec git tini openssh-client && \ + apk add --no-cache bash su-exec git tini 'openssh-client>=10.3_p1-r1' && \ mkdir -p /usr/local/lib/docker/cli-plugins # Copy the source-built Docker CLI and Compose plugin from their builder stages. diff --git a/backend/package-lock.json b/backend/package-lock.json index 5b0bed56..94b320b9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -32,7 +32,8 @@ "otplib": "^13.4.0", "semver": "^7.7.4", "systeminformation": "^5.31.1", - "tar-stream": "^3.1.8", + "tar-stream": "3.2.0", + "undici": "^8.10.0", "ws": "^8.19.0", "yaml": "^2.8.2", "zod": "^4.3.6" @@ -73,16 +74,16 @@ } }, "node_modules/@aws-sdk/checksums": { - "version": "3.1000.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz", - "integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==", + "version": "3.1000.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.29.tgz", + "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -90,19 +91,19 @@ } }, "node_modules/@aws-sdk/client-ecr": { - "version": "3.1111.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1111.0.tgz", - "integrity": "sha512-H0oAs3nDo3gSXwcTRg2pGfT7xfs8kkHWA3iQToRGAWk73tYXbFIp3TsEYqJ+jw9Rx/Rd7+ybgBU6jEgntAB3lQ==", + "version": "3.1121.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1121.0.tgz", + "integrity": "sha512-XuncSMLHCqE0NUgXcLJroY1zjrwc1bbwl2Q9iaFAEGd3GMYQg2WRX0s9Jna55D1sf0dCTKslfeIksxQo9P55Mw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/credential-provider-node": "^3.972.80", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.81", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -110,22 +111,22 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1111.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz", - "integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==", + "version": "3.1121.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1121.0.tgz", + "integrity": "sha512-hBnoqaVBeWdkgXcJElMXA2yUZWkBCBntu2qmN+tfqmzC+j4LzJC3ox8qIgS2WdMS1cb8UwyBogUVrkRXybNm0A==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/checksums": "^3.1000.28", - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/credential-provider-node": "^3.972.80", - "@aws-sdk/middleware-sdk-s3": "^3.972.74", - "@aws-sdk/signature-v4-multi-region": "^3.996.45", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", + "@aws-sdk/checksums": "^3.1000.29", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.81", + "@aws-sdk/middleware-sdk-s3": "^3.972.75", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -133,18 +134,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", - "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/types": "^3.974.4", - "@aws-sdk/xml-builder": "^3.972.39", + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.31.1", + "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -153,16 +154,16 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.69", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", - "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -170,18 +171,18 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.71", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", - "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -189,24 +190,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", - "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/credential-provider-env": "^3.972.69", - "@aws-sdk/credential-provider-http": "^3.972.71", - "@aws-sdk/credential-provider-login": "^3.972.76", - "@aws-sdk/credential-provider-process": "^3.972.69", - "@aws-sdk/credential-provider-sso": "^3.973.13", - "@aws-sdk/credential-provider-web-identity": "^3.972.75", - "@aws-sdk/nested-clients": "^3.997.43", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -214,17 +215,17 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.76", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz", - "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==", + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/nested-clients": "^3.997.43", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -232,22 +233,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.80", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", - "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz", + "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.69", - "@aws-sdk/credential-provider-http": "^3.972.71", - "@aws-sdk/credential-provider-ini": "^3.973.14", - "@aws-sdk/credential-provider-process": "^3.972.69", - "@aws-sdk/credential-provider-sso": "^3.973.13", - "@aws-sdk/credential-provider-web-identity": "^3.972.75", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -255,16 +256,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.69", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", - "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -272,18 +273,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", - "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/nested-clients": "^3.997.43", - "@aws-sdk/token-providers": "3.1111.0", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -291,17 +292,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.75", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", - "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/nested-clients": "^3.997.43", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -309,17 +310,17 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.74", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz", - "integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz", + "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/signature-v4-multi-region": "^3.996.45", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -327,19 +328,19 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz", - "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==", + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/signature-v4-multi-region": "^3.996.45", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -347,15 +348,15 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.45", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz", - "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==", + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/types": "^3.974.4", + "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -363,17 +364,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1111.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", - "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.977.8", - "@aws-sdk/nested-clients": "^3.997.43", - "@aws-sdk/types": "^3.974.4", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -381,13 +382,13 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", - "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -395,13 +396,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz", - "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==", + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -649,29 +650,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -771,45 +786,45 @@ } }, "node_modules/@otplib/core": { - "version": "13.4.1", - "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz", - "integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.5.0.tgz", + "integrity": "sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ==", "license": "MIT" }, "node_modules/@otplib/hotp": { - "version": "13.4.1", - "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz", - "integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.5.0.tgz", + "integrity": "sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw==", "license": "MIT", "dependencies": { - "@otplib/core": "13.4.1", - "@otplib/uri": "13.4.1" + "@otplib/core": "13.5.0", + "@otplib/uri": "13.5.0" } }, "node_modules/@otplib/plugin-base32-scure": { - "version": "13.4.1", - "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz", - "integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.5.0.tgz", + "integrity": "sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA==", "license": "MIT", "dependencies": { - "@otplib/core": "13.4.1", + "@otplib/core": "13.5.0", "@scure/base": "^2.2.0" } }, "node_modules/@otplib/plugin-crypto-noble": { - "version": "13.4.1", - "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz", - "integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.5.0.tgz", + "integrity": "sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA==", "license": "MIT", "dependencies": { "@noble/hashes": "^2.2.0", - "@otplib/core": "13.4.1" + "@otplib/core": "13.5.0" } }, "node_modules/@otplib/plugin-crypto-noble/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -819,23 +834,23 @@ } }, "node_modules/@otplib/totp": { - "version": "13.4.1", - "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz", - "integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.5.0.tgz", + "integrity": "sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg==", "license": "MIT", "dependencies": { - "@otplib/core": "13.4.1", - "@otplib/hotp": "13.4.1", - "@otplib/uri": "13.4.1" + "@otplib/core": "13.5.0", + "@otplib/hotp": "13.5.0", + "@otplib/uri": "13.5.0" } }, "node_modules/@otplib/uri": { - "version": "13.4.1", - "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz", - "integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.5.0.tgz", + "integrity": "sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw==", "license": "MIT", "dependencies": { - "@otplib/core": "13.4.1" + "@otplib/core": "13.5.0" } }, "node_modules/@oxc-project/types": { @@ -1180,18 +1195,18 @@ "license": "MIT" }, "node_modules/@scure/base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", - "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@smithy/core": { - "version": "3.33.2", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.2.tgz", - "integrity": "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==", + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1218,14 +1233,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.13", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", - "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1233,14 +1248,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.9.13", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", - "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1551,9 +1566,9 @@ } }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -1684,17 +1699,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", + "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/type-utils": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1707,7 +1722,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1723,16 +1738,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "engines": { @@ -1748,14 +1763,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", "debug": "^4.4.3" }, "engines": { @@ -1770,14 +1785,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1788,9 +1803,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", "dev": true, "license": "MIT", "engines": { @@ -1805,15 +1820,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", + "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1830,9 +1845,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", "dev": true, "license": "MIT", "engines": { @@ -1844,16 +1859,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1872,16 +1887,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", + "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1896,13 +1911,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1914,16 +1929,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1932,13 +1947,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1959,9 +1974,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1972,13 +1987,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1986,14 +2001,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2002,9 +2017,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -2012,13 +2027,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2205,9 +2220,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", - "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -3202,9 +3217,9 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", "workspaces": [ @@ -3481,9 +3496,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", - "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -3533,9 +3548,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -4732,9 +4747,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", - "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -4982,9 +4997,9 @@ } }, "node_modules/openid-client": { - "version": "6.8.5", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.5.tgz", - "integrity": "sha512-jNGC/5wnTYwCcEUe2ss0IRUmVRQcgxM0A1nLb3eX/9llqNbMWOQd2xd+qDAgfVCpA5Qh96Y1cdnkfbva6+bSdA==", + "version": "6.8.7", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.7.tgz", + "integrity": "sha512-gtKthNu7evSBvTdrrlHb4F3Fi9dcwlb5QaITlCs+9mfpvuOi0Q3qtBf5+iY4sEP8hy1qCoAdxBNPDcmZeVSDzQ==", "license": "MIT", "dependencies": { "jose": "^6.2.8", @@ -5013,17 +5028,17 @@ } }, "node_modules/otplib": { - "version": "13.4.1", - "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz", - "integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==", + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.5.0.tgz", + "integrity": "sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==", "license": "MIT", "dependencies": { - "@otplib/core": "13.4.1", - "@otplib/hotp": "13.4.1", - "@otplib/plugin-base32-scure": "13.4.1", - "@otplib/plugin-crypto-noble": "13.4.1", - "@otplib/totp": "13.4.1", - "@otplib/uri": "13.4.1" + "@otplib/core": "13.5.0", + "@otplib/hotp": "13.5.0", + "@otplib/plugin-base32-scure": "13.5.0", + "@otplib/plugin-crypto-noble": "13.5.0", + "@otplib/totp": "13.5.0", + "@otplib/uri": "13.5.0" } }, "node_modules/p-limit": { @@ -5235,12 +5250,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -5487,14 +5503,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -5506,13 +5522,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -5744,9 +5760,9 @@ } }, "node_modules/systeminformation": { - "version": "5.33.1", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.1.tgz", - "integrity": "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==", + "version": "5.33.6", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.6.tgz", + "integrity": "sha512-hMOQG/eRUzuopuYGGdl8ntkau0nEC7fOaRoTUg1RSr2GTQIk2VNa76DA0+ApajkGfzmcgAupgIP/vt+jtoe5EA==", "license": "MIT", "os": [ "darwin", @@ -5893,9 +5909,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -6068,16 +6084,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", - "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", + "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.67.0", - "@typescript-eslint/parser": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0" + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6098,6 +6114,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -6237,19 +6262,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6277,12 +6302,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -6501,9 +6526,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/backend/package.json b/backend/package.json index 881cc376..ceb19e23 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "test:docker-integration": "vitest run --config vitest.docker-integration.config.ts", "test": "vitest run", "lint": "eslint src", + "matrix:render": "node scripts/git-support-matrix/render.js", "reset-mfa": "node dist/cli/resetMfa.js", "reset-password": "node dist/cli/resetPassword.js", "create-emergency-admin": "node dist/cli/createEmergencyAdmin.js", @@ -88,7 +89,8 @@ "otplib": "^13.4.0", "semver": "^7.7.4", "systeminformation": "^5.31.1", - "tar-stream": "^3.1.8", + "tar-stream": "3.2.0", + "undici": "^8.10.0", "ws": "^8.19.0", "yaml": "^2.8.2", "zod": "^4.3.6" diff --git a/backend/scripts/git-support-matrix/loadClaimSet.js b/backend/scripts/git-support-matrix/loadClaimSet.js new file mode 100644 index 00000000..17f740b1 --- /dev/null +++ b/backend/scripts/git-support-matrix/loadClaimSet.js @@ -0,0 +1,30 @@ +// Shared YAML loader for the Git transport support matrix. +// +// Used by both render.js (the CLI/build-time generator) and +// git-support-matrix.test.ts (the validator), so the two never parse the +// source files differently. +const fs = require('fs'); +const path = require('path'); +const { parse } = require('yaml'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); +const SUPPORT_YAML_PATH = path.join(REPO_ROOT, 'docs', 'git-transport-support.yaml'); +const ATTESTATIONS_YAML_PATH = path.join(REPO_ROOT, 'docs', 'git-transport-attestations.yaml'); +const MDX_PATH = path.join(REPO_ROOT, 'docs', 'features', 'git-transport-support.mdx'); + +function loadClaimSet() { + const supportRaw = fs.readFileSync(SUPPORT_YAML_PATH, 'utf8'); + const attestationsRaw = fs.readFileSync(ATTESTATIONS_YAML_PATH, 'utf8'); + return { + support: parse(supportRaw), + attestations: parse(attestationsRaw), + }; +} + +module.exports = { + REPO_ROOT, + SUPPORT_YAML_PATH, + ATTESTATIONS_YAML_PATH, + MDX_PATH, + loadClaimSet, +}; diff --git a/backend/scripts/git-support-matrix/render.js b/backend/scripts/git-support-matrix/render.js new file mode 100644 index 00000000..4cf503d2 --- /dev/null +++ b/backend/scripts/git-support-matrix/render.js @@ -0,0 +1,199 @@ +// Generates the tables in docs/features/git-transport-support.mdx from +// docs/git-transport-support.yaml. +// +// Everything between the GENERATED markers is produced here; the rest of the +// MDX file (intro prose, the "How these claims are verified" section) is +// hand-written and left untouched. The validator (git-support-matrix.test.ts) +// imports renderFullMdx and asserts the committed file is byte-identical to +// what it produces, so the page can never silently drift from the YAML. +const fs = require('fs'); +const { loadClaimSet, MDX_PATH } = require('./loadClaimSet'); + +const MARKER_BEGIN = ''; +const MARKER_END = ''; + +const TRANSPORT_LABELS = { https: 'HTTPS', ssh: 'SSH' }; +const TRANSPORT_NOTES = { + https: 'Personal Access Token for private repositories, or no credential at all for public ones. TLS verification uses the system trust store by default, or a per-source custom CA when configured.', + ssh: 'A read-only deploy key with strict host-key verification. Standard (22) and nonstandard ports are both supported.', +}; + +const REF_LABELS = { branch: 'Branch', tag: 'Tag', sha: 'Commit SHA' }; +const REF_NOTES = { + branch: 'Tracks the head of a branch; each pull resolves and pins the exact commit.', + tag: 'Both annotated and lightweight tags resolve to their target commit.', + sha: 'A full commit SHA is pinned directly; the Git host must advertise the commit on some branch or tag.', +}; + +const AUTH_LABELS = { none: 'Public (no auth)', pat: 'Personal Access Token', 'deploy-key': 'SSH deploy key' }; +const AUTH_NOTES = { + none: 'For public repositories.', + pat: 'Stored encrypted at rest, never returned after save.', + 'deploy-key': 'Stored encrypted at rest; the server host key is verified on every fetch.', +}; + +const CA_LABELS = { system: 'System trust (default)', 'per-source': 'Per-source custom CA', 'not-applicable': 'Not applicable' }; +const CA_NOTES = { + system: 'The host running the fetch trusts its system certificate store.', + 'per-source': "Combined with the system trust anchors, so public hosts keep validating normally. Redirects are re-resolved and only followed when they stay on the source's own host.", + 'not-applicable': 'SSH uses host-key verification instead of TLS certificate trust.', +}; + +const HOST_LABELS = { + generic: 'Generic (self-hosted or any Git server)', + github: 'GitHub', + gitlab: 'GitLab', + gitea: 'Gitea', + forgejo: 'Forgejo', + bitbucket: 'Bitbucket', +}; +const HOST_ORDER = ['generic', 'github', 'gitlab', 'gitea', 'forgejo', 'bitbucket']; + +const STATUS_LABELS = { supported: 'Supported', unsupported: 'Not supported', unverified: 'Not yet verified' }; + +function aggregateStatus(claims) { + if (claims.length === 0) return 'unverified'; + if (claims.some((c) => c.support === 'unsupported')) return 'unsupported'; + if (claims.some((c) => c.support === 'supported')) return 'supported'; + return 'unverified'; +} + +function evidenceSummary(claims, attestationsById) { + const kinds = new Set(); + let latestDate = null; + for (const c of claims) { + if (c.support !== 'supported' || !c.evidence) continue; + if (c.evidence.kind === 'automated') kinds.add('automated'); + if (c.evidence.kind === 'live') { + kinds.add('live'); + const att = attestationsById.get(c.evidence.attestation); + if (att && (!latestDate || att.date > latestDate)) latestDate = att.date; + } + } + if (kinds.size === 0) return 'Pending'; + if (kinds.has('automated') && kinds.has('live')) return `Automated, every change; live as of ${latestDate}`; + if (kinds.has('automated')) return 'Automated, every change'; + return `Live, ${latestDate}`; +} + +function table(headers, rows) { + const sep = headers.map(() => '---'); + return [ + `| ${headers.join(' | ')} |`, + `| ${sep.join(' | ')} |`, + ...rows.map((r) => `| ${r.join(' | ')} |`), + ].join('\n'); +} + +function renderTransports(claims) { + const rows = Object.keys(TRANSPORT_LABELS).map((t) => { + const group = claims.filter((c) => c.transport === t); + return [TRANSPORT_LABELS[t], STATUS_LABELS[aggregateStatus(group)], TRANSPORT_NOTES[t]]; + }); + return ['## Transports', '', table(['Transport', 'Status', 'Notes'], rows)].join('\n'); +} + +function renderRefs(claims) { + const rows = Object.keys(REF_LABELS).map((r) => { + const group = claims.filter((c) => c.ref === r); + return [REF_LABELS[r], STATUS_LABELS[aggregateStatus(group)], REF_NOTES[r]]; + }); + return ['## Reference types', '', table(['Reference type', 'Status', 'Notes'], rows)].join('\n'); +} + +function renderAuth(claims) { + const rows = Object.keys(AUTH_LABELS).map((a) => { + const group = claims.filter((c) => c.auth === a); + return [AUTH_LABELS[a], STATUS_LABELS[aggregateStatus(group)], AUTH_NOTES[a]]; + }); + return ['## Authentication', '', table(['Method', 'Status', 'Notes'], rows)].join('\n'); +} + +const CA_TABLE_MODES = ['system', 'per-source']; + +function renderCa(claims) { + const rows = CA_TABLE_MODES.map((c) => { + const group = claims.filter((claim) => claim.ca === c); + return [CA_LABELS[c], STATUS_LABELS[aggregateStatus(group)], CA_NOTES[c]]; + }); + return ['## TLS and certificate authorities', '', table(['Mode', 'Status', 'Notes'], rows)].join('\n'); +} + +function renderHosts(claims, attestationsById) { + const rows = HOST_ORDER.map((host) => { + const group = claims.filter((c) => c.host === host); + const httpsGroup = group.filter((c) => c.transport === 'https'); + const sshGroup = group.filter((c) => c.transport === 'ssh'); + const branchGroup = group.filter((c) => c.ref === 'branch'); + const tagGroup = group.filter((c) => c.ref === 'tag'); + const shaGroup = group.filter((c) => c.ref === 'sha'); + return [ + HOST_LABELS[host], + STATUS_LABELS[aggregateStatus(httpsGroup)], + STATUS_LABELS[aggregateStatus(sshGroup)], + STATUS_LABELS[aggregateStatus(branchGroup)], + STATUS_LABELS[aggregateStatus(tagGroup)], + STATUS_LABELS[aggregateStatus(shaGroup)], + evidenceSummary(group, attestationsById), + ]; + }); + return [ + '## Git hosts', + '', + table(['Host', 'HTTPS', 'SSH', 'Branch', 'Tag', 'Commit SHA', 'Evidence'], rows), + ].join('\n'); +} + +function renderLimitations(limitations) { + const bullets = limitations.map((l) => `- **${l.title}.** ${l.statement}`); + return ['## Not supported', '', ...bullets].join('\n'); +} + +function renderGeneratedBlock(data) { + const { support, attestations } = data; + const attestationsById = new Map((attestations.attestations || []).map((a) => [a.id, a])); + const claims = support.claims; + return [ + renderTransports(claims), + '', + renderRefs(claims), + '', + renderAuth(claims), + '', + renderHosts(claims, attestationsById), + '', + renderCa(claims), + '', + renderLimitations(support.limitations), + ].join('\n'); +} + +function renderFullMdx() { + const data = loadClaimSet(); + const generated = renderGeneratedBlock(data); + const current = fs.readFileSync(MDX_PATH, 'utf8'); + + const beginIdx = current.indexOf(MARKER_BEGIN); + const endIdx = current.indexOf(MARKER_END); + if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) { + throw new Error(`${MDX_PATH} is missing the GENERATED markers, or they are out of order.`); + } + + const before = current.slice(0, beginIdx + MARKER_BEGIN.length); + const after = current.slice(endIdx); + return `${before}\n\n${generated}\n\n${after}`; +} + +if (require.main === module) { + const rendered = renderFullMdx(); + fs.writeFileSync(MDX_PATH, rendered, 'utf8'); + console.log(`[matrix:render] Wrote ${MDX_PATH}`); +} + +module.exports = { + MARKER_BEGIN, + MARKER_END, + renderGeneratedBlock, + renderFullMdx, + aggregateStatus, +}; diff --git a/backend/src/__tests__/AutoHealService.evaluate.test.ts b/backend/src/__tests__/AutoHealService.evaluate.test.ts index e9e530a8..e6114425 100644 --- a/backend/src/__tests__/AutoHealService.evaluate.test.ts +++ b/backend/src/__tests__/AutoHealService.evaluate.test.ts @@ -399,6 +399,7 @@ describe('AutoHealService.evaluate', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', + trustedLoopback: false, }); const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); @@ -434,6 +435,7 @@ describe('AutoHealService.evaluate', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote2:1852', apiToken: 'tok2', + trustedLoopback: false, }); const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); diff --git a/backend/src/__tests__/__helpers__/externalDeps.ts b/backend/src/__tests__/__helpers__/externalDeps.ts new file mode 100644 index 00000000..07347349 --- /dev/null +++ b/backend/src/__tests__/__helpers__/externalDeps.ts @@ -0,0 +1,47 @@ +/** + * Shared availability probes for the real-git and real-sshd integration + * suites. + * + * Every suite used to carry its own copy of `gitAvailable()`/`sshdAvailable()` + * and pass the result straight to `describe.skipIf`, so a missing dependency + * in CI silently skipped the suite instead of failing the build: proof of a + * combination could stop running with nothing in the test output to say so. + * These wrappers keep the same local-dev behavior (skip when the dependency + * is absent) but throw under CI, where the dependency is expected to be + * present and a skip would be a false claim of coverage. + */ +import { spawnSync } from 'child_process'; + +export type DependencyProbe = () => boolean; + +export const defaultGitProbe: DependencyProbe = () => + spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; + +export const defaultSshdProbe: DependencyProbe = () => + spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0; + +function requireDependency(name: string, hint: string, probe: DependencyProbe): boolean { + const present = probe(); + if (!present && process.env.CI) { + throw new Error(`${name} is required in CI but was not found. ${hint}`); + } + return present; +} + +/** True when the system `git` binary is available; throws under CI if not. */ +export function requireGitBinary(probe: DependencyProbe = defaultGitProbe): boolean { + return requireDependency( + 'git', + 'Ensure the CI image installs the git CLI before running the backend suite.', + probe, + ); +} + +/** True when a local `sshd` binary is available; throws under CI if not. */ +export function requireSshd(probe: DependencyProbe = defaultSshdProbe): boolean { + return requireDependency( + 'sshd', + 'Ensure the CI image installs openssh-server and frees loopback port 22 (see .github/workflows/ci.yml).', + probe, + ); +} diff --git a/backend/src/__tests__/__helpers__/gitFixture.ts b/backend/src/__tests__/__helpers__/gitFixture.ts new file mode 100644 index 00000000..03a10c92 --- /dev/null +++ b/backend/src/__tests__/__helpers__/gitFixture.ts @@ -0,0 +1,47 @@ +/** + * Shared fixture helpers for tests that need a real local git repository + * served over smart HTTPS. Two tests in this directory build the same shape + * of bare repo (init source, write compose, commit, bare-clone) for their own + * TLS servers; this helper keeps that logic in one place. + */ +import { spawnSync } from 'child_process'; +import { mkdtempSync, writeFileSync } from 'fs'; +import os from 'os'; +import path from 'path'; + +export interface BuildBareRepoOptions { + /** Tmpdir prefix for the working source repo. */ + srcPrefix?: string; + /** Tmpdir prefix for the bare clone. */ + barePrefix?: string; + /** Git user.email for the fixture commit. */ + userEmail?: string; + /** Git user.name for the fixture commit. */ + userName?: string; + /** Branch name; defaults to 'main'. */ + branch?: string; +} + +export function buildBareRepo(opts: BuildBareRepoOptions = {}): string { + const srcPrefix = opts.srcPrefix ?? 'sencho-git-src-'; + const barePrefix = opts.barePrefix ?? 'sencho-git-bare-'; + const userEmail = opts.userEmail ?? 'git-fixture@sencho.test'; + const userName = opts.userName ?? 'Sencho Git Fixture'; + const branch = opts.branch ?? 'main'; + const srcDir = mkdtempSync(path.join(os.tmpdir(), srcPrefix)); + const run = (args: string[], cwd: string, label: string) => { + const r = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`git ${label} failed: ${r.stderr}`); + }; + run(['init', '-b', branch], srcDir, 'init'); + run(['config', 'user.email', userEmail], srcDir, 'config email'); + run(['config', 'user.name', userName], srcDir, 'config name'); + writeFileSync(path.join(srcDir, 'compose.yaml'), 'services:\n x:\n image: nginx\n'); + run(['add', '-A'], srcDir, 'add'); + run(['commit', '-m', 'fixture'], srcDir, 'commit'); + const bareRoot = mkdtempSync(path.join(os.tmpdir(), barePrefix)); + const bareDir = path.join(bareRoot, 'repo.git'); + const clone = spawnSync('git', ['clone', '--bare', '--quiet', srcDir, bareDir], { encoding: 'utf8' }); + if (clone.status !== 0) throw new Error(`git clone --bare failed: ${clone.stderr}`); + return bareDir; +} diff --git a/backend/src/__tests__/__helpers__/testHandleResolver.ts b/backend/src/__tests__/__helpers__/testHandleResolver.ts new file mode 100644 index 00000000..a35a9028 --- /dev/null +++ b/backend/src/__tests__/__helpers__/testHandleResolver.ts @@ -0,0 +1,150 @@ +/** + * Resolves a (file, exact test title) proof handle against real vitest + * source, using the TypeScript compiler API rather than a string search. + * + * A string search accepts a commented-out test, a `.skip`-ed test, or a + * duplicate title landing on the wrong declaration. This walks the actual + * AST: it finds every `it`/`test` declaration with that literal title, + * rejects any declaration carrying a skip-shaped modifier (`.skip`, `.todo`, + * `.failing`, `.only`, `.each`, `.skipIf`, `.runIf`) directly, and rejects + * any declaration nested under an enclosing `describe` that is unconditionally + * skipped or conditionally skipped by anything other than + * `describe.skipIf(...)` whose predicate calls one of the approved hardened + * dependency probes (`requireGitBinary`, `requireSshd` from + * `./externalDeps`). `describe.skip` and `describe.runIf` are never + * approved, at any nesting depth. + */ +import ts from 'typescript'; + +export const APPROVED_GUARD_HELPERS = ['requireGitBinary', 'requireSshd']; + +const SKIP_SHAPED_MODIFIERS = new Set(['skip', 'todo', 'failing', 'only', 'each', 'skipIf', 'runIf']); + +export type HandleResolution = + | { ok: true } + | { ok: false; reason: 'not-found' | 'duplicate' | 'skipped-directly' | 'unapproved-ancestor-skip' }; + +interface CallShape { + kind: string; + modifier: string | null; + /** For a curried modifier (`X.skipIf(pred)(title, fn)`), the inner call's first argument. */ + predicateArg?: ts.Node; +} + +const CURRIED_MODIFIERS = new Set(['skipIf', 'runIf', 'each']); + +function classifyCall(node: ts.CallExpression): CallShape | null { + const expr = node.expression; + + // Direct form: describe('x', fn) / describe.skip('x', fn) / it.todo('x'). + if (ts.isIdentifier(expr)) { + return { kind: expr.text, modifier: null }; + } + if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) { + return { kind: expr.expression.text, modifier: expr.name.text }; + } + + // Curried form: X.skipIf(pred)(title, fn) / X.runIf(pred)(title, fn) / + // X.each(cases)(title, fn): the outer call's expression is itself a + // CallExpression whose own expression is the X.modifier access. + if (ts.isCallExpression(expr) && ts.isPropertyAccessExpression(expr.expression) && ts.isIdentifier(expr.expression.expression)) { + const modifier = expr.expression.name.text; + if (CURRIED_MODIFIERS.has(modifier)) { + return { kind: expr.expression.expression.text, modifier, predicateArg: expr.arguments[0] }; + } + } + return null; +} + +function predicateCallsApprovedHelper(argNode: ts.Node): boolean { + let found = false; + const visit = (n: ts.Node): void => { + if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && APPROVED_GUARD_HELPERS.includes(n.expression.text)) { + found = true; + } + ts.forEachChild(n, visit); + }; + visit(argNode); + return found; +} + +function stringLiteralText(node: ts.Node | undefined): string | null { + if (node && ts.isStringLiteralLike(node)) return node.text; + return null; +} + +function functionBodyArgument(node: ts.CallExpression): ts.ArrowFunction | ts.FunctionExpression | undefined { + return node.arguments.find((a): a is ts.ArrowFunction | ts.FunctionExpression => + ts.isArrowFunction(a) || ts.isFunctionExpression(a)); +} + +type AncestorState = 'none' | 'approved' | 'unapproved'; + +interface FoundDeclaration { + title: string; + ownModifier: string | null; + ancestorState: AncestorState; +} + +function collectDeclarations(sourceFile: ts.SourceFile): FoundDeclaration[] { + const found: FoundDeclaration[] = []; + + function walk(node: ts.Node, ancestorState: AncestorState): void { + if (ts.isCallExpression(node)) { + const classified = classifyCall(node); + if (classified?.kind === 'describe') { + let nextState: AncestorState = ancestorState; + if (ancestorState !== 'unapproved') { + if (classified.modifier === 'skip' || classified.modifier === 'runIf') { + nextState = 'unapproved'; + } else if (classified.modifier === 'skipIf') { + const predicate = classified.predicateArg; + nextState = predicate && predicateCallsApprovedHelper(predicate) ? 'approved' : 'unapproved'; + } else if (classified.modifier === 'only' || classified.modifier === 'each' || classified.modifier === 'todo') { + // Scoping/parameterization modifiers on describe don't skip + // this suite's tests; leave ancestorState unchanged. + nextState = ancestorState; + } + } + const callback = functionBodyArgument(node); + if (callback?.body) { + ts.forEachChild(callback.body, (child) => walk(child, nextState)); + } + return; + } + if (classified?.kind === 'it' || classified?.kind === 'test') { + const title = stringLiteralText(node.arguments[0]); + if (title !== null) { + found.push({ title, ownModifier: classified.modifier, ancestorState }); + } + // Do not descend further into an it/test call's own arguments; + // its callback body is the test implementation, not more + // declarations. + return; + } + } + ts.forEachChild(node, (child) => walk(child, ancestorState)); + } + + walk(sourceFile, 'none'); + return found; +} + +export function resolveTestHandle(filePath: string, sourceText: string, title: string): HandleResolution { + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true); + const declarations = collectDeclarations(sourceFile).filter((d) => d.title === title); + + if (declarations.length === 0) return { ok: false, reason: 'not-found' }; + + const runnable = declarations.filter((d) => d.ownModifier === null && d.ancestorState !== 'unapproved'); + if (runnable.length > 1) return { ok: false, reason: 'duplicate' }; + if (runnable.length === 1) return { ok: true }; + + // Every matching declaration is skipped some way; report the most + // specific reason from the first match. + const first = declarations[0]; + if (first.ownModifier !== null && SKIP_SHAPED_MODIFIERS.has(first.ownModifier)) { + return { ok: false, reason: 'skipped-directly' }; + } + return { ok: false, reason: 'unapproved-ancestor-skip' }; +} diff --git a/backend/src/__tests__/authored-compose-args.test.ts b/backend/src/__tests__/authored-compose-args.test.ts index 45a1d768..ba30b575 100644 --- a/backend/src/__tests__/authored-compose-args.test.ts +++ b/backend/src/__tests__/authored-compose-args.test.ts @@ -50,6 +50,7 @@ function seedSource(stackName: string, composePaths: string[]): void { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, diff --git a/backend/src/__tests__/blueprints-remote-deploy.test.ts b/backend/src/__tests__/blueprints-remote-deploy.test.ts index 534a536e..48c6fa86 100644 --- a/backend/src/__tests__/blueprints-remote-deploy.test.ts +++ b/backend/src/__tests__/blueprints-remote-deploy.test.ts @@ -63,6 +63,7 @@ beforeEach(() => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); const db = DatabaseService.getInstance().getDb(); db.prepare('DELETE FROM blueprint_deployments').run(); diff --git a/backend/src/__tests__/bootstrap-startup-gitops-order.test.ts b/backend/src/__tests__/bootstrap-startup-gitops-order.test.ts new file mode 100644 index 00000000..6ccf2e57 --- /dev/null +++ b/backend/src/__tests__/bootstrap-startup-gitops-order.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let runGitOpsSourceRecovery: typeof import('../bootstrap/startup').runGitOpsSourceRecovery; +let GitSourceService: typeof import('../services/GitSourceService').GitSourceService; +let gitSourceServiceModule: typeof import('../services/GitSourceService'); + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ runGitOpsSourceRecovery } = await import('../bootstrap/startup')); + gitSourceServiceModule = await import('../services/GitSourceService'); + ({ GitSourceService } = gitSourceServiceModule); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +describe('runGitOpsSourceRecovery', () => { + it('recovers unsettled reconcile attempts before sweeping the managed area', async () => { + const order: string[] = []; + // Recovery yields before recording itself: if the sweep were ever + // started concurrently instead of strictly after recovery resolves, + // the sweep's own synchronous push would land first and this would + // catch it, rather than merely proving call order at invocation + // time. + vi.spyOn(GitSourceService.getInstance(), 'recoverUnsettledReconcileAttempts') + .mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + order.push('recover'); + }); + vi.spyOn(gitSourceServiceModule, 'sweepGitManifestOrphans') + .mockImplementation(async () => { order.push('sweep'); }); + + await runGitOpsSourceRecovery(); + + expect(order).toEqual(['recover', 'sweep']); + }); + + it('still runs the sweep when recovery itself throws, tolerating the failure', async () => { + const order: string[] = []; + vi.spyOn(GitSourceService.getInstance(), 'recoverUnsettledReconcileAttempts') + .mockImplementation(async () => { throw new Error('simulated recovery failure'); }); + vi.spyOn(gitSourceServiceModule, 'sweepGitManifestOrphans') + .mockImplementation(async () => { order.push('sweep'); }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(runGitOpsSourceRecovery()).resolves.toBeUndefined(); + + expect(order).toEqual(['sweep']); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('still resolves when the sweep itself throws, tolerating the failure rather than aborting startup', async () => { + const recoverSpy = vi.spyOn(GitSourceService.getInstance(), 'recoverUnsettledReconcileAttempts') + .mockImplementation(async () => {}); + vi.spyOn(gitSourceServiceModule, 'sweepGitManifestOrphans') + .mockImplementation(async () => { throw new Error('simulated sweep failure'); }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect(runGitOpsSourceRecovery()).resolves.toBeUndefined(); + + expect(recoverSpy).toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalled(); + }); +}); + +describe('startServer source ordering', () => { + it('calls runGitOpsSourceRecovery before starting SourceController, so recovery and the sweep always precede the controller poll loop', () => { + // A structural check, not a behavioral one: startServer drives ~25 + // unrelated services and isn't practical to run end to end in a + // test. What matters here is the one property a future edit could + // silently break: SourceController must never start before + // recovery and the sweep have both awaited to completion. Comments + // are stripped first so a mention of either symbol in prose can't + // satisfy the match, and matching is whitespace/chaining-tolerant + // so a harmless reformat (line-wrapped method chain, a `const` + // extracted for the controller instance) doesn't false-fail this. + const source = fs.readFileSync(path.join(__dirname, '../bootstrap/startup.ts'), 'utf-8'); + const startServerStart = source.indexOf('export async function startServer'); + // startServer is the last top-level declaration in this file today; + // if that ever changes, bound this slice to its closing brace + // instead of running to end of file. + const startServerBody = source.slice(startServerStart); + const code = startServerBody.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); + const reconcileCallIndex = code.search(/await\s+runGitOpsSourceRecovery\s*\(/); + const controllerStartIndex = code.search(/SourceController\s*\.\s*getInstance\s*\(\s*\)\s*\.\s*start\s*\(/); + expect(reconcileCallIndex).toBeGreaterThan(-1); + expect(controllerStartIndex).toBeGreaterThan(-1); + expect(reconcileCallIndex).toBeLessThan(controllerStartIndex); + }); +}); diff --git a/backend/src/__tests__/build-info-route.test.ts b/backend/src/__tests__/build-info-route.test.ts new file mode 100644 index 00000000..1b6a5bbc --- /dev/null +++ b/backend/src/__tests__/build-info-route.test.ts @@ -0,0 +1,225 @@ +/** + * Route coverage for the canonical build identity: + * + * - GET /api/build-info is proxy-exempt (always served by the control + * instance), requires a signed-in human session (rejects machine / API-token + * credentials), redacts hardened image references to non-admins via + * `restricted: true`, and never mislabels a redacted field "Unknown". + * - GET /api/meta exposes only the bounded `buildChannel` enum on the public + * surface and never leaks the running image reference. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets'; + +let tmpDir: string; +let app: import('express').Express; +let adminAuth: string; +let viewerAuth: string; +let machineAuth: string; +let remoteNodeId: number; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let SelfIdentityService: typeof import('../services/SelfIdentityService').default; + +const IMAGE_ID = 'b'.repeat(64); +const DIGEST = 'a'.repeat(64); + +function mockBuildInfo(over: Record = {}) { + const svc = SelfIdentityService.getInstance(); + vi.spyOn(svc, 'getBuildInfo').mockReturnValue({ + version: '0.97.1', + channel: 'dev', + imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev-abc1234', + imageId: IMAGE_ID, + revision: 'dev-abc1234', + ...over, + }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ DatabaseService } = await import('../services/DatabaseService')); + SelfIdentityService = (await import('../services/SelfIdentityService')).default; + + const db = DatabaseService.getInstance(); + remoteNodeId = db.addNode({ + name: 'build-info-remote', + type: 'remote', + compose_dir: '/tmp', + is_default: false, + api_url: 'http://127.0.0.1:1', + api_token: 'build-info-remote-token', + }); + // A signed-in non-admin human session (role resolved from the DB row). + db.addUser({ + username: 'build-info-viewer', + password_hash: await bcrypt.hash('pw', 1), + role: 'viewer', + }); + + adminAuth = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; + viewerAuth = `Bearer ${jwt.sign({ username: 'build-info-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; + // node_proxy machine credential: authMiddleware maps it to role admin with + // userId 0, so requireUserSession must reject it as not a human session. + machineAuth = `Bearer ${jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +afterEach(() => vi.restoreAllMocks()); + +describe('GET /api/build-info auth', () => { + it('requires authentication', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/build-info'); + expect(res.status).toBe(401); + }); + + it('rejects node_proxy machine credentials (not a human session)', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/build-info').set('Authorization', machineAuth); + expect(res.status).toBe(403); + expect(res.body.code).toBe('SESSION_REQUIRED'); + }); +}); + +describe('GET /api/build-info is proxy-exempt', () => { + it('serves locally even when x-node-id targets a remote node', async () => { + mockBuildInfo(); + // The remote node's api_url is a closed loopback port. A 502 would mean the + // proxy intercepted the request; anything else proves the local handler + // matched, exactly as the existing /api/nodes proxy-exempt test asserts. + const res = await withLoopbackTargetProtection(() => request(app) + .get('/api/build-info') + .set('Authorization', adminAuth) + .set('x-node-id', String(remoteNodeId))); + expect(res.status).not.toBe(502); + expect(res.body.channel).toBe('dev'); + }); +}); + +describe('GET /api/build-info as admin', () => { + it('returns the full dev identity for a dev image (regression: semver still previous stable)', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/build-info').set('Authorization', adminAuth); + expect(res.status).toBe(200); + expect(res.body.version).toBe('0.97.1'); + expect(res.body.channel).toBe('dev'); + expect(res.body.imageChannel).toBe('community'); + expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev-abc1234'); + expect(res.body.imageId).toBe(IMAGE_ID); + expect(res.body.revision).toBe('dev-abc1234'); + expect(res.body.restricted).toBe(false); + }); + + it('returns the bounded imageChannel for a hardened image', async () => { + mockBuildInfo({ + channel: 'stable', + imageRef: 'ghcr.io/studio-saelix/sencho-hardened:0.97.1', + }); + const res = await request(app).get('/api/build-info').set('Authorization', adminAuth); + expect(res.status).toBe(200); + expect(res.body.channel).toBe('stable'); + expect(res.body.imageChannel).toBe('hardened'); + expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-hardened:0.97.1'); + expect(res.body.restricted).toBe(false); + }); +}); + +describe('GET /api/build-info as a non-admin', () => { + it('redacts a hardened image reference and revision to a non-admin via restricted:true', async () => { + mockBuildInfo({ + channel: 'stable', + imageRef: 'ghcr.io/studio-saelix/sencho-hardened:0.97.1', + revision: `sha256:${DIGEST}`, + }); + const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth); + expect(res.status).toBe(200); + // The build channel stays stable; the procurement channel is what gates + // redaction. Both reference fields are nulled with restricted:true so the + // UI can label them "Restricted", never "Unknown". + expect(res.body.channel).toBe('stable'); + expect(res.body.imageChannel).toBe('hardened'); + expect(res.body.imageRef).toBeNull(); + expect(res.body.revision).toBeNull(); + expect(res.body.restricted).toBe(true); + // The image ID is not a registry reference and is always returned. + expect(res.body.imageId).toBe(IMAGE_ID); + }); + + it('does not redact a community image for a non-admin', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth); + expect(res.status).toBe(200); + expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev-abc1234'); + expect(res.body.revision).toBe('dev-abc1234'); + expect(res.body.restricted).toBe(false); + }); + + it('reports unknown procurement channel on bare metal without a running reference', async () => { + mockBuildInfo({ channel: 'unknown', imageRef: null, revision: null }); + const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth); + expect(res.status).toBe(200); + // No reference means no classification, and no redaction can apply. + expect(res.body.channel).toBe('unknown'); + expect(res.body.imageChannel).toBe('unknown'); + expect(res.body.imageRef).toBeNull(); + expect(res.body.revision).toBeNull(); + expect(res.body.restricted).toBe(false); + }); +}); + +describe('GET /api/build-info awaits revision enrichment', () => { + it('blocks the response until the enrichment settle promise resolves', async () => { + mockBuildInfo(); + const svc = SelfIdentityService.getInstance(); + let release!: () => void; + vi.spyOn(svc, 'whenRevisionResolved').mockImplementation( + () => new Promise((res) => { release = res; }), + ); + + let settled = false; + const pending = request(app) + .get('/api/build-info') + .set('Authorization', adminAuth) + .then((res) => { settled = true; return res; }); + + // Give the route a tick to reach the await. It must not have responded yet, + // proving a transient null is never the settled value of a success. + await new Promise((r) => setTimeout(r, 10)); + expect(settled).toBe(false); + + release(); + const res = await pending; + expect(res.status).toBe(200); + expect(res.body.revision).toBe('dev-abc1234'); + }); +}); + +describe('GET /api/meta buildChannel', () => { + it('exposes the bounded build channel on the public endpoint', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/meta'); + expect(res.status).toBe(200); + expect(res.body.buildChannel).toBe('dev'); + }); + + it('never leaks the running image reference on the public endpoint', async () => { + mockBuildInfo(); + const res = await request(app).get('/api/meta'); + const body = JSON.stringify(res.body); + expect(body).not.toContain('ghcr.io/studio-saelix/sencho-dev'); + expect(body).not.toContain('dev-abc1234'); + }); + + it('omits buildChannel when the running image reference is unknown', async () => { + mockBuildInfo({ imageRef: null, channel: 'unknown', revision: null }); + const res = await request(app).get('/api/meta'); + expect(res.status).toBe(200); + expect(res.body.buildChannel).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/backend/src/__tests__/cache-endpoints.test.ts b/backend/src/__tests__/cache-endpoints.test.ts index 47236f82..e67f07a6 100644 --- a/backend/src/__tests__/cache-endpoints.test.ts +++ b/backend/src/__tests__/cache-endpoints.test.ts @@ -359,6 +359,7 @@ describe('GET /api/stacks/statuses caching', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, diff --git a/backend/src/__tests__/capability-registry-pilot.test.ts b/backend/src/__tests__/capability-registry-pilot.test.ts index 04231a62..52759ddf 100644 --- a/backend/src/__tests__/capability-registry-pilot.test.ts +++ b/backend/src/__tests__/capability-registry-pilot.test.ts @@ -33,8 +33,16 @@ describe('fetchRemoteMeta Authorization header', () => { await fetchRemoteMeta('https://remote.example.com:1852', 'real-token'); expect(getSpy).toHaveBeenCalledTimes(1); - const init = getSpy.mock.calls[0][1] as { headers: Record }; + const init = getSpy.mock.calls[0][1] as { + headers: Record; + proxy?: boolean; + httpAgent?: unknown; + httpsAgent?: unknown; + }; expect(init.headers).toEqual({ Authorization: 'Bearer real-token' }); + expect(init.proxy).toBe(false); + expect(init.httpAgent).toBeDefined(); + expect(init.httpsAgent).toBeDefined(); }); it('omits Authorization entirely when token is empty (pilot-agent loopback)', async () => { @@ -42,18 +50,21 @@ describe('fetchRemoteMeta Authorization header', () => { data: { version: '0.76.7', capabilities: ['stacks'], startedAt: 1, updateError: null }, }); - await fetchRemoteMeta('http://127.0.0.1:54321', ''); + await fetchRemoteMeta('http://127.0.0.1:54321', '', true); expect(getSpy).toHaveBeenCalledTimes(1); - const init = getSpy.mock.calls[0][1] as { headers: Record }; + const init = getSpy.mock.calls[0][1] as { headers: Record; proxy?: boolean }; expect(init.headers).toEqual({}); expect(init.headers).not.toHaveProperty('Authorization'); + expect(init.proxy).toBe(false); + expect(init).not.toHaveProperty('httpAgent'); + expect(init).not.toHaveProperty('httpsAgent'); }); it('returns OFFLINE_META shape on transport failure', async () => { vi.spyOn(axios, 'get').mockRejectedValue(new Error('connect ECONNREFUSED')); - const meta = await fetchRemoteMeta('http://127.0.0.1:54321', ''); + const meta = await fetchRemoteMeta('http://127.0.0.1:54321', '', true); expect(meta).toEqual({ version: null, diff --git a/backend/src/__tests__/classify-build-channel.test.ts b/backend/src/__tests__/classify-build-channel.test.ts new file mode 100644 index 00000000..825fa1d4 --- /dev/null +++ b/backend/src/__tests__/classify-build-channel.test.ts @@ -0,0 +1,42 @@ +/** + * Truth table for classifyBuildChannel, the canonical build-identity classifier. + * Answers "is this a dev, preview, or stable build" from the image reference + * alone, independent of the packaged semver. + */ +import { describe, it, expect } from 'vitest'; +import { classifyBuildChannel } from '../helpers/selfUpdateCompose'; + +describe('classifyBuildChannel', () => { + it('classifies the dev repository as dev regardless of tag', () => { + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:dev')).toBe('dev'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:dev-abc1234')).toBe('dev'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:latest')).toBe('dev'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev@sha256:abc')).toBe('dev'); + }); + + it('classifies stable-repo preview tags as preview', () => { + expect(classifyBuildChannel('saelix/sencho:pr-42')).toBe('preview'); + expect(classifyBuildChannel('saelix/sencho:preview-abc1234')).toBe('preview'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho:pr-7')).toBe('preview'); + }); + + it('classifies stable-repo release/floating tags as stable', () => { + expect(classifyBuildChannel('saelix/sencho:0.97.1')).toBe('stable'); + expect(classifyBuildChannel('saelix/sencho:latest')).toBe('stable'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho:v1.2.3')).toBe('stable'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-hardened:1.2.3')).toBe('stable'); + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-hardened:latest')).toBe('stable'); + }); + + it('treats a dev- tag as preview-matching-tolerant (still dev repo wins)', () => { + // dev repo takes precedence over the preview/stable tag classification + expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:pr-42')).toBe('dev'); + }); + + it('classifies unknown repositories as unknown', () => { + expect(classifyBuildChannel('ubuntu:22.04')).toBe('unknown'); + expect(classifyBuildChannel('registry.example.com/private/app:1.0')).toBe('unknown'); + expect(classifyBuildChannel('')).toBe('unknown'); + expect(classifyBuildChannel(' ')).toBe('unknown'); + }); +}); \ No newline at end of file diff --git a/backend/src/__tests__/database-metrics.test.ts b/backend/src/__tests__/database-metrics.test.ts index 0e4b37c5..6dc9be3c 100644 --- a/backend/src/__tests__/database-metrics.test.ts +++ b/backend/src/__tests__/database-metrics.test.ts @@ -4,6 +4,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import type { NotificationHistory } from '../services/DatabaseService'; let tmpDir: string; let DatabaseService: any; @@ -183,6 +184,39 @@ describe('DatabaseService - cleanupOldNotifications', () => { }); }); +describe('DatabaseService - notification history GitOps dedupe', () => { + it('inserts once and returns the existing row on a repeated dedupe_key', () => { + const first = db.addNotificationHistory(0, { + level: 'info', + message: 'source reconciled', + timestamp: Date.now(), + gitops_operation_id: 'op-1', + dedupe_key: 'gitops:app-1:op-1', + }); + const second = db.addNotificationHistory(0, { + level: 'info', + message: 'source reconciled (retry repair)', + timestamp: Date.now(), + gitops_operation_id: 'op-1', + dedupe_key: 'gitops:app-1:op-1', + }); + + expect(second.id).toBe(first.id); + const history: NotificationHistory[] = db.getNotificationHistory(0, 200); + const matches = history.filter((n) => n.dedupe_key === 'gitops:app-1:op-1'); + expect(matches).toHaveLength(1); + expect(matches[0].message).toBe('source reconciled'); + }); + + it('allows two rows with no dedupe_key, matching existing notification behavior', () => { + db.addNotificationHistory(0, { level: 'info', message: 'plain a', timestamp: Date.now() }); + db.addNotificationHistory(0, { level: 'info', message: 'plain b', timestamp: Date.now() }); + + const history: NotificationHistory[] = db.getNotificationHistory(0, 200); + expect(history.filter((n) => n.message === 'plain a' || n.message === 'plain b')).toHaveLength(2); + }); +}); + describe('DatabaseService - cleanupOldAuditLogs', () => { it('deletes audit logs older than specified days and retains recent ones', () => { const oldTimestamp = Date.now() - 120 * 24 * 60 * 60 * 1000; // 120 days ago diff --git a/backend/src/__tests__/diagnostics-route.test.ts b/backend/src/__tests__/diagnostics-route.test.ts index a8ed0af3..e82c0ce9 100644 --- a/backend/src/__tests__/diagnostics-route.test.ts +++ b/backend/src/__tests__/diagnostics-route.test.ts @@ -7,6 +7,7 @@ import request from 'supertest'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcrypt'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { resetTrustedProxyBlockListCache } from '../helpers/trustedProxyCidrs'; let tmpDir: string; let app: import('express').Express; @@ -100,4 +101,35 @@ describe('GET /api/diagnostics/environment', () => { }); } }); + + it('ignores a forwarded HTTPS scheme from an untrusted peer', async () => { + const res = await request(app) + .get('/api/diagnostics/environment') + .set('Authorization', adminAuthHeader) + .set('Host', 'sencho.example.com') + .set('X-Forwarded-Proto', 'https'); + + expect(res.status).toBe(200); + const tls = (res.body.checks as Array<{ id: string; status: string }>).find(check => check.id === 'tls'); + expect(tls?.status).toBe('warn'); + }); + + it('honors a forwarded HTTPS scheme from an allowlisted proxy peer', async () => { + process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8'; + resetTrustedProxyBlockListCache(); + try { + const res = await request(app) + .get('/api/diagnostics/environment') + .set('Authorization', adminAuthHeader) + .set('Host', 'sencho.example.com') + .set('X-Forwarded-Proto', 'https'); + + expect(res.status).toBe(200); + const tls = (res.body.checks as Array<{ id: string; status: string }>).find(check => check.id === 'tls'); + expect(tls?.status).toBe('pass'); + } finally { + delete process.env.SENCHO_TRUSTED_PROXY_CIDRS; + resetTrustedProxyBlockListCache(); + } + }); }); diff --git a/backend/src/__tests__/externalDeps.test.ts b/backend/src/__tests__/externalDeps.test.ts new file mode 100644 index 00000000..12d19574 --- /dev/null +++ b/backend/src/__tests__/externalDeps.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { requireGitBinary, requireSshd } from './__helpers__/externalDeps'; + +describe('external dependency probes', () => { + const originalCi = process.env.CI; + + afterEach(() => { + if (originalCi === undefined) delete process.env.CI; + else process.env.CI = originalCi; + }); + + describe('requireGitBinary', () => { + it('returns true when git is present, locally or in CI', () => { + delete process.env.CI; + expect(requireGitBinary(() => true)).toBe(true); + process.env.CI = '1'; + expect(requireGitBinary(() => true)).toBe(true); + }); + + it('returns false when git is absent locally', () => { + delete process.env.CI; + expect(requireGitBinary(() => false)).toBe(false); + }); + + it('throws when git is absent under CI', () => { + process.env.CI = '1'; + expect(() => requireGitBinary(() => false)).toThrow(/git is required in CI/); + }); + }); + + describe('requireSshd', () => { + it('returns true when sshd is present, locally or in CI', () => { + delete process.env.CI; + expect(requireSshd(() => true)).toBe(true); + process.env.CI = '1'; + expect(requireSshd(() => true)).toBe(true); + }); + + it('returns false when sshd is absent locally', () => { + delete process.env.CI; + expect(requireSshd(() => false)).toBe(false); + }); + + it('throws when sshd is absent under CI', () => { + process.env.CI = '1'; + expect(() => requireSshd(() => false)).toThrow(/sshd is required in CI/); + }); + }); +}); diff --git a/backend/src/__tests__/fleet-actions.test.ts b/backend/src/__tests__/fleet-actions.test.ts index 3987fbf0..afba45ed 100644 --- a/backend/src/__tests__/fleet-actions.test.ts +++ b/backend/src/__tests__/fleet-actions.test.ts @@ -786,7 +786,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => { it('fans out to the remote local-assign receiver with Bearer auth and the template body', async () => { const remoteId = addRemote('assign-remote-ok'); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false }); const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( JSON.stringify({ created: true, results: [{ stackName: 'r1', success: true }] }), { status: 200, headers: { 'content-type': 'application/json' } }, @@ -814,7 +814,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => { it('omits the Authorization header for a pilot-agent remote with an empty token', async () => { const remoteId = addRemote('assign-remote-pilot', 'pilot_agent'); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:9', apiToken: '' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:9', apiToken: '', trustedLoopback: true }); const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( JSON.stringify({ created: false, results: [{ stackName: 'p1', success: true }] }), { status: 200, headers: { 'content-type': 'application/json' } }, @@ -850,7 +850,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => { it('treats a mixed-version remote (404 on local-assign) as a per-node failure', async () => { const remoteId = addRemote('assign-remote-404'); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false }); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 })); const res = await request(app) @@ -867,7 +867,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => { it('reports a transport failure as a per-node failure', async () => { const remoteId = addRemote('assign-remote-transport'); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false }); vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED')); const res = await request(app) @@ -883,7 +883,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => { it('reports a malformed 200 body as a per-node failure', async () => { const remoteId = addRemote('assign-remote-malformed'); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false }); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( JSON.stringify({ created: true, results: 'not-an-array' }), { status: 200, headers: { 'content-type': 'application/json' } }, @@ -903,7 +903,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => { it('fails a node whose 200 body returns an empty results array for a non-empty request', async () => { const remoteId = addRemote('assign-remote-empty'); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false }); // A well-shaped { created, results } body whose results are empty used to // pass the bare Array.isArray check and read as a successful zero-stack // assign. Membership validation must fail the node instead. @@ -926,7 +926,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => { it('fails a node whose results omit one of the requested stacks', async () => { const remoteId = addRemote('assign-remote-partial'); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false }); // Two stacks requested, only one row returned: a partial body the control // must not accept as a clean assign of the covered stack alone. vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( diff --git a/backend/src/__tests__/fleet-pilot-agent-parity.test.ts b/backend/src/__tests__/fleet-pilot-agent-parity.test.ts index 379322cb..9d2167e7 100644 --- a/backend/src/__tests__/fleet-pilot-agent-parity.test.ts +++ b/backend/src/__tests__/fleet-pilot-agent-parity.test.ts @@ -66,7 +66,7 @@ afterEach(() => { function mockTargetActive() { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => { - if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '' }; + if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '', trustedLoopback: true }; return null; }); } diff --git a/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts b/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts index aa5f6f3f..c05f9733 100644 --- a/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts +++ b/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts @@ -90,8 +90,8 @@ afterEach(() => { function mockTargets(opts: { pilotReachable: boolean; proxyReachable: boolean } = { pilotReachable: true, proxyReachable: true }) { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => { - if (id === pilotNodeId) return opts.pilotReachable ? { apiUrl: PILOT_LOOPBACK, apiToken: '' } : null; - if (id === proxyNodeId) return opts.proxyReachable ? { apiUrl: PROXY_URL, apiToken: PROXY_TOKEN } : null; + if (id === pilotNodeId) return opts.pilotReachable ? { apiUrl: PILOT_LOOPBACK, apiToken: '', trustedLoopback: true } : null; + if (id === proxyNodeId) return opts.proxyReachable ? { apiUrl: PROXY_URL, apiToken: PROXY_TOKEN, trustedLoopback: false } : null; return null; }); } diff --git a/backend/src/__tests__/fleet-pilot-update.test.ts b/backend/src/__tests__/fleet-pilot-update.test.ts index 2f4228ba..e8b06397 100644 --- a/backend/src/__tests__/fleet-pilot-update.test.ts +++ b/backend/src/__tests__/fleet-pilot-update.test.ts @@ -104,8 +104,8 @@ afterEach(() => { function mockTargetForPilot() { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => { - if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '' }; - if (id === proxyNodeId) return { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token' }; + if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '', trustedLoopback: true }; + if (id === proxyNodeId) return { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token', trustedLoopback: false }; return null; }); } diff --git a/backend/src/__tests__/fleet-snapshot-routes.test.ts b/backend/src/__tests__/fleet-snapshot-routes.test.ts index 08324347..ae06e233 100644 --- a/backend/src/__tests__/fleet-snapshot-routes.test.ts +++ b/backend/src/__tests__/fleet-snapshot-routes.test.ts @@ -627,7 +627,7 @@ describe('Snapshot restore: remote dossier notes (proxy PUT)', () => { it('writes notes to a remote node via the proxy dossier PUT when opted in', async () => { const { id, remoteId } = remoteDocSnapshot('rweb', 'documented'); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', trustedLoopback: false }); const calls: Array<{ url: string; method?: string }> = []; vi.stubGlobal('fetch', vi.fn(async (url: string, opts?: { method?: string }) => { calls.push({ url, method: opts?.method }); @@ -647,7 +647,7 @@ describe('Snapshot restore: remote dossier notes (proxy PUT)', () => { it('reports a non-fatal notesError when the remote dossier PUT fails but files restored', async () => { const { id, remoteId } = remoteDocSnapshot('rweb2', 'documented'); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', trustedLoopback: false }); vi.stubGlobal('fetch', vi.fn(async (url: string) => { if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response; return { ok: true, status: 200, text: async () => '' } as unknown as Response; @@ -668,7 +668,7 @@ describe('Snapshot restore: remote dossier notes (proxy PUT)', () => { // restore-all is driven by snapshot id; the target node is resolved from // the snapshot's stored files, so the returned remoteId is not needed here. const { id } = remoteDocSnapshot('rweb3', 'documented'); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', trustedLoopback: false }); vi.stubGlobal('fetch', vi.fn(async (url: string) => { if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response; return { ok: true, status: 200, text: async () => '' } as unknown as Response; @@ -928,6 +928,7 @@ describe('Snapshot restore: recovery generation contract', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', + trustedLoopback: false, }); } diff --git a/backend/src/__tests__/fleet-update-hardening.test.ts b/backend/src/__tests__/fleet-update-hardening.test.ts index 834d5454..b04fb3cc 100644 --- a/backend/src/__tests__/fleet-update-hardening.test.ts +++ b/backend/src/__tests__/fleet-update-hardening.test.ts @@ -51,7 +51,7 @@ function mockMeta(meta: RemoteMeta) { function mockTarget() { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => - id === proxyNodeId ? { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token' } : null, + id === proxyNodeId ? { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token', trustedLoopback: false } : null, ); } diff --git a/backend/src/__tests__/git-ca-bundle.test.ts b/backend/src/__tests__/git-ca-bundle.test.ts new file mode 100644 index 00000000..85226909 --- /dev/null +++ b/backend/src/__tests__/git-ca-bundle.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { validateCaBundlePem, credentialScopeHost } from '../services/git/caBundle'; +import { classifyGitFailure } from '../services/git/errors'; + +describe('validateCaBundlePem', () => { + it('accepts a PEM certificate block', () => { + const pem = '-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv\n-----END CERTIFICATE-----\n'; + expect(validateCaBundlePem(pem)).toBe(pem.trim()); + }); + + it('rejects empty and non-PEM input', () => { + expect(validateCaBundlePem('')).toBeNull(); + expect(validateCaBundlePem('not a cert')).toBeNull(); + }); +}); + +describe('credentialScopeHost', () => { + it('normalizes host and non-default port', () => { + expect(credentialScopeHost('Git.Example.COM')).toBe('git.example.com'); + expect(credentialScopeHost('git.example.com', 8443)).toBe('git.example.com:8443'); + expect(credentialScopeHost('git.example.com:8443')).toBe('git.example.com:8443'); + }); +}); + +describe('classifyGitFailure TLS and redirect nuance', () => { + it('maps hostname mismatch to a clear TLS message', () => { + const result = classifyGitFailure({ + transportFailure: true, + reason: 'exit', + host: 'git.example.com', + hasToken: false, + stderr: 'SSL: certificate subject name does not match target host name', + }); + expect(result.message).toContain('hostname does not match'); + }); + + it('maps curl\'s IP-address SAN mismatch wording to the same clear TLS message', () => { + const result = classifyGitFailure({ + transportFailure: true, + reason: 'exit', + host: 'git.example.com', + hasToken: false, + stderr: "SSL: no alternative certificate subject name matches target ipv4 address '172.18.0.1'", + }); + expect(result.message).toContain('hostname does not match'); + }); + + it('maps expired certificates distinctly', () => { + const result = classifyGitFailure({ + transportFailure: true, + reason: 'exit', + host: 'git.example.com', + hasToken: false, + stderr: 'certificate has expired', + }); + expect(result.message).toContain('expired'); + }); + + it('maps unknown CA to private-CA guidance', () => { + const result = classifyGitFailure({ + transportFailure: true, + reason: 'exit', + host: 'git.example.com', + hasToken: false, + stderr: 'SSL certificate problem: unable to get local issuer certificate', + }); + expect(result.message).toContain('private CA'); + }); + + it('maps redirect-scope and redirect stderr to credential-scope guidance', () => { + const scoped = classifyGitFailure({ + transportFailure: true, + reason: 'redirect-scope', + host: 'git.example.com', + hasToken: true, + }); + expect(scoped.message).toContain('redirected'); + + const stderr = classifyGitFailure({ + transportFailure: true, + reason: 'exit', + host: 'git.example.com', + hasToken: true, + stderr: 'The requested URL returned error: 302', + }); + expect(stderr.message).toContain('redirected'); + }); +}); diff --git a/backend/src/__tests__/git-private-ca.integration.test.ts b/backend/src/__tests__/git-private-ca.integration.test.ts new file mode 100644 index 00000000..34bd9ffb --- /dev/null +++ b/backend/src/__tests__/git-private-ca.integration.test.ts @@ -0,0 +1,127 @@ +/** + * Proves per-source CA bundles work without the process-wide NODE_EXTRA_CA_CERTS bridge. + */ +import { spawn } from 'child_process'; +import { promises as fs, readFileSync } from 'fs'; +import https from 'https'; +import os from 'os'; +import path from 'path'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { nativeGitTransport } from '../services/git/nativeGitTransport'; +import { buildBareRepo } from './__helpers__/gitFixture'; +import { requireGitBinary } from './__helpers__/externalDeps'; + +const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures'); +const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8'); + +function serveRepo(bareDir: string): Promise<{ url: string; close: () => void }> { + return new Promise((resolve, reject) => { + const server = https.createServer( + { + cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')), + key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')), + }, + (req, res) => { + const url = req.url ?? '/'; + if (!url.startsWith('/repo.git/')) { + res.statusCode = 404; + res.end('unknown repo'); + return; + } + const pathname = url.slice('/repo.git'.length).split('?')[0]; + if (pathname === '/info/refs' && (req.method === 'GET' || req.method === 'POST')) { + const ps = spawn('git', ['upload-pack', '--stateless-rpc', '--advertise-refs', bareDir]); + let out = Buffer.alloc(0); + ps.stdout.on('data', (d: Buffer) => { out = Buffer.concat([out, d]); }); + ps.on('close', (code) => { + if (code !== 0) { + res.statusCode = 500; + res.end('git upload-pack failed'); + return; + } + res.setHeader('content-type', 'application/x-git-upload-pack-advertisement'); + res.end(Buffer.concat([Buffer.from('001e# service=git-upload-pack\n0000'), out])); + }); + return; + } + if (pathname === '/git-upload-pack' && req.method === 'POST') { + const ps = spawn('git', ['upload-pack', '--stateless-rpc', bareDir]); + res.setHeader('content-type', 'application/x-git-upload-pack-result'); + ps.stdout.pipe(res); + req.pipe(ps.stdin); + return; + } + res.statusCode = 404; + res.end('unsupported'); + }, + ); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('server did not bind')); + return; + } + resolve({ url: `https://127.0.0.1:${address.port}/repo.git`, close: () => server.close() }); + }); + }); +} + +describe.skipIf(!requireGitBinary())('per-source private CA transport (real git)', () => { + let repoUrl: string; + let closeServer: () => void; + let prevExtraCaCerts: string | undefined; + const workspaces: string[] = []; + + beforeAll(async () => { + const bareDir = buildBareRepo({ srcPrefix: 'sencho-ca-src-', barePrefix: 'sencho-ca-bare-', userEmail: 'ca-test@sencho.test', userName: 'Sencho CA Test' }); + const served = await serveRepo(bareDir); + repoUrl = served.url; + closeServer = served.close; + prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS; + delete process.env.NODE_EXTRA_CA_CERTS; + }); + + afterAll(() => { + closeServer?.(); + if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS; + else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts; + }); + + afterEach(async () => { + await Promise.all(workspaces.splice(0).map((d) => fs.rm(d, { recursive: true, force: true }))); + }); + + it('clones a private-CA HTTPS repo when the per-source CA PEM is supplied', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-ca-ws-')); + workspaces.push(workspaceRoot); + const resolved = await nativeGitTransport.resolveRef({ + repoUrl, + ref: 'main', + caBundlePem: CA_PEM, + workspaceRoot, + timeoutMs: 30_000, + }); + const fetched = await nativeGitTransport.fetchAtCommit({ + repoUrl, + ref: 'main', + refKind: resolved.kind, + commitSha: resolved.commitSha, + caBundlePem: CA_PEM, + workspaceRoot, + maxBytes: 50 * 1024 * 1024, + }); + expect(fetched.commitSha).toMatch(/^[0-9a-f]{40}$/); + }); + + it('fails TLS verification without a matching per-source CA when NODE_EXTRA_CA_CERTS is unset', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-ca-ws-')); + workspaces.push(workspaceRoot); + await expect(nativeGitTransport.resolveRef({ + repoUrl, + ref: 'main', + workspaceRoot, + timeoutMs: 30_000, + })).rejects.toMatchObject({ transportFailure: true }); + }); +}); diff --git a/backend/src/__tests__/git-project-manifest.test.ts b/backend/src/__tests__/git-project-manifest.test.ts index 5733e0bc..464e3012 100644 --- a/backend/src/__tests__/git-project-manifest.test.ts +++ b/backend/src/__tests__/git-project-manifest.test.ts @@ -92,6 +92,7 @@ function makeClone(files: Record): string { } const REPO = { repo_url: 'https://github.com/example/repo.git', branch: 'main' }; +const NO_CANDIDATE_CLAIMS = { complete: true as const, dirs: new Set() }; function seedGitSource(stackName: string): void { DatabaseService.getInstance().upsertGitSource({ @@ -105,6 +106,7 @@ function seedGitSource(stackName: string): void { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -271,6 +273,7 @@ describe('promoteGeneration', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -729,6 +732,59 @@ describe('promoteGeneration', () => { }); describe('sweepManagedArea (crash recovery)', () => { + it('does not delete a candidate when its completion marker cannot be inspected', async () => { + const svc = GitProjectManifestService.getInstance(); + const stackName = 'sweep-candidate-marker-io'; + const candidateAbs = path.join(tmpDir, 'git-managed', '1', stackName, 'generations', 'candidate-marker-io'); + const markerPath = path.join(candidateAbs, CANDIDATE_COMPLETE_MARKER); + fs.mkdirSync(candidateAbs, { recursive: true }); + fs.writeFileSync(markerPath, 'complete'); + const originalAccess = fs.promises.access.bind(fs.promises); + const accessSpy = vi.spyOn(fs.promises, 'access').mockImplementation(async (...args: Parameters) => { + if (String(args[0]) === markerPath) { + throw Object.assign(new Error('candidate marker permission denied'), { code: 'EACCES' }); + } + return originalAccess(...args); + }); + + try { + await expect(svc.sweepManagedArea(stackName, { + repoUrl: REPO.repo_url, + branch: REPO.branch, + stackExists: true, + candidateClaims: NO_CANDIDATE_CLAIMS, + })).rejects.toThrow(/candidate marker permission denied/); + expect(fs.existsSync(candidateAbs)).toBe(true); + } finally { + accessSpy.mockRestore(); + } + }); + + it('surfaces a generations-directory read failure', async () => { + const svc = GitProjectManifestService.getInstance(); + const stackName = 'sweep-generations-read-io'; + const generationsDir = path.join(tmpDir, 'git-managed', '1', stackName, 'generations'); + fs.mkdirSync(generationsDir, { recursive: true }); + const originalReaddir = fs.promises.readdir.bind(fs.promises); + const readdirSpy = vi.spyOn(fs.promises, 'readdir').mockImplementation(async (...args: Parameters) => { + if (String(args[0]) === generationsDir) { + throw Object.assign(new Error('generations directory unavailable'), { code: 'EIO' }); + } + return originalReaddir(...args); + }); + + try { + await expect(svc.sweepManagedArea(stackName, { + repoUrl: REPO.repo_url, + branch: REPO.branch, + stackExists: true, + candidateClaims: NO_CANDIDATE_CLAIMS, + })).rejects.toThrow(/generations directory unavailable/); + } finally { + readdirSpy.mockRestore(); + } + }); + it('restores the previous applied generation when the marker matches the stack dir', async () => { const svc = GitProjectManifestService.getInstance(); const stackName = 'sweep-restore'; @@ -761,7 +817,7 @@ describe('sweepManagedArea (crash recovery)', () => { affected: ['app.env', 'compose.yaml'], }); - await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true }); + await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS }); expect(readStackFile(stackName, 'compose.yaml')).toBe('PRIOR\n'); expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false); @@ -781,6 +837,7 @@ describe('sweepManagedArea (crash recovery)', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -814,7 +871,7 @@ describe('sweepManagedArea (crash recovery)', () => { affected: ['compose.yaml'], }); - await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true }); + await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS }); expect(readStackFile(stackName, 'compose.yaml')).toBe('OPERATOR FIXED ME\n'); expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false); @@ -849,7 +906,7 @@ describe('sweepManagedArea (crash recovery)', () => { affected: ['compose.yaml'], }); - await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true }); + await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS }); expect(readStackFile(stackName, 'compose.yaml')).toBe('PRIOR\n'); expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false); @@ -884,7 +941,7 @@ describe('sweepManagedArea (crash recovery)', () => { affected: ['compose.yaml'], }); - await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true }); + await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS }); expect(readStackFile(stackName, 'compose.yaml')).toBe('NEW\n'); expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false); @@ -924,7 +981,7 @@ describe('sweepManagedArea (crash recovery)', () => { affected: ['compose.yaml'], }); - await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true }); + await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS }); expect(readStackFile(stackName, 'compose.yaml')).toBe('PRIOR\n'); const row = DatabaseService.getInstance().getGitSource(stackName); @@ -966,7 +1023,7 @@ describe('sweepManagedArea (crash recovery)', () => { affected: ['app.env', 'compose.yaml'], }); - await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true }); + await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS }); expect(readStackFile(stackName, 'compose.yaml')).toBe('NEW\n'); expect(readStackFile(stackName, 'app.env')).toBe('OPERATOR\n'); @@ -978,7 +1035,7 @@ describe('sweepManagedArea (crash recovery)', () => { const svc = GitProjectManifestService.getInstance(); const stackName = 'sweep-orphan'; await svc.writeManifest(stackName, buildManifest(stackName, [managedEntry({ materializedPath: 'compose.yaml' })])); - await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: false }); + await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: false, candidateClaims: NO_CANDIDATE_CLAIMS }); expect(await svc.readManifest(stackName, REPO.repo_url, REPO.branch)).toBeNull(); }); }); @@ -1056,7 +1113,7 @@ describe('detach crash recovery', () => { writeStackFile(stackName, 'compose.yaml', 'services:\n web:\n image: nginx:new\n'); expect(await svc.stageManagedAreaForDetach(stackName)).toBe(true); - await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true }); + await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS }); expect(readStackFile(stackName, 'compose.yaml')).toBe(original.toString('utf8')); const restored = await svc.readManifest(stackName, REPO.repo_url, REPO.branch); @@ -1287,6 +1344,7 @@ describe('promoteGeneration mid-write failure recovery', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -1299,7 +1357,7 @@ describe('promoteGeneration mid-write failure recovery', () => { }); fs.mkdirSync(path.join(tmpDir, 'git-managed', '1', stackName), { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER), '{"v":3 torn', 'utf8'); - await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true }); + await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS }); expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false); expect(DatabaseService.getInstance().getGitSource(stackName)?.manifest_state).toBe('migration_required'); }); @@ -1321,7 +1379,7 @@ describe('promoteGeneration mid-write failure recovery', () => { }), 'utf8'); const stateSpy = vi.spyOn(DatabaseService.getInstance(), 'setGitSourceManifestState'); try { - await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true })).resolves.toBeUndefined(); + await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS })).resolves.toBeUndefined(); expect(stateSpy).toHaveBeenCalledWith(stackName, null, 'migration_required', null); } finally { stateSpy.mockRestore(); @@ -1339,7 +1397,7 @@ describe('promoteGeneration mid-write failure recovery', () => { throw new Error('database unavailable'); }); try { - await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true })).rejects.toThrow(/database unavailable/); + await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS })).rejects.toThrow(/database unavailable/); expect(fs.existsSync(markerPath)).toBe(true); } finally { stateSpy.mockRestore(); @@ -1367,7 +1425,7 @@ describe('promoteGeneration mid-write failure recovery', () => { return originalAccess(...args); }); try { - await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true })).rejects.toThrow(/permission denied/); + await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS })).rejects.toThrow(/permission denied/); expect(fs.existsSync(markerPath)).toBe(true); } finally { accessSpy.mockRestore(); diff --git a/backend/src/__tests__/git-redirect-preflight.test.ts b/backend/src/__tests__/git-redirect-preflight.test.ts new file mode 100644 index 00000000..b72b975a --- /dev/null +++ b/backend/src/__tests__/git-redirect-preflight.test.ts @@ -0,0 +1,281 @@ +/** + * Redirect policy against real HTTPS servers. + * + * These run in-process (no git subprocess), so they exercise the preflight + * walk itself: which destinations are approved, which are refused, and + * crucially whether a refused destination is contacted at all. Each fixture + * counts its own requests, so "rejected before contact" is asserted as an + * observed request count rather than inferred from the thrown error. + */ +import { readFileSync } from 'fs'; +import https from 'https'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { looksLikeRedirectFailure, resolveRedirectedRepoUrl } from '../services/git/redirectPreflight'; + +const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures'); +const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8'); +const TLS_OPTS = { + cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')), + key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')), +}; + +interface Fixture { + port: number; + origin: string; + /** Every path this server was asked for, in order. */ + hits: string[]; + close: () => void; +} + +const open: Fixture[] = []; + +/** Start a TLS server whose handler may redirect; records every request path. */ +function serve(handler: (url: string, res: import('http').ServerResponse) => void): Promise { + return new Promise((resolve, reject) => { + const hits: string[] = []; + const server = https.createServer(TLS_OPTS, (req, res) => { + hits.push(req.url ?? ''); + handler(req.url ?? '', res); + }); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('fixture did not bind')); + return; + } + const fixture: Fixture = { + port: address.port, + origin: `https://127.0.0.1:${address.port}`, + hits, + close: () => server.close(), + }; + open.push(fixture); + resolve(fixture); + }); + }); +} + +/** Answers the ref-advertise with a 200 so a chain can terminate successfully. */ +function ok(res: import('http').ServerResponse): void { + res.statusCode = 200; + res.end('refs'); +} + +afterEach(() => { + open.splice(0).forEach((f) => f.close()); +}); + +describe('redirect preflight', () => { + it('approves a same-origin redirect and returns the relocated repository URL', async () => { + const server = await serve((url, res) => { + if (url.startsWith('/old.git/')) { + res.statusCode = 302; + res.setHeader('location', url.replace('/old.git/', '/new.git/')); + res.end(); + return; + } + ok(res); + }); + + const resolved = await resolveRedirectedRepoUrl({ + repoUrl: `${server.origin}/old.git`, + hasToken: true, + reportHost: '127.0.0.1', + caPem: CA_PEM, + }); + + expect(resolved).toBe(`${server.origin}/new.git`); + }); + + it('approves a same-origin redirect expressed as an absolute Location', async () => { + // The case above sends a relative Location; this one sends the fully + // qualified form, so both branches of the Location parser are covered. + let origin = ''; + const server = await serve((url, res) => { + if (url.startsWith('/old.git/')) { + res.statusCode = 302; + res.setHeader('location', `${origin}${url.replace('/old.git/', '/moved.git/')}`); + res.end(); + return; + } + ok(res); + }); + origin = server.origin; + + await expect( + resolveRedirectedRepoUrl({ + repoUrl: `${server.origin}/old.git`, + hasToken: false, + reportHost: '127.0.0.1', + caPem: CA_PEM, + }), + ).resolves.toBe(`${server.origin}/moved.git`); + }); + + it('inserts the ref-advertise suffix into the path, not after an existing query string', async () => { + // A repoUrl of `/repo.git?temp=1` (a signed URL, say) must not turn + // into `/repo.git?temp=1/info/refs?service=...`: the query has to be + // replaced, not appended to. + const server = await serve((_url, res) => ok(res)); + + await expect( + resolveRedirectedRepoUrl({ + repoUrl: `${server.origin}/repo.git?temp=1`, + hasToken: true, + reportHost: '127.0.0.1', + caPem: CA_PEM, + }), + ).resolves.toBeNull(); + + expect(server.hits).toEqual(['/repo.git/info/refs?service=git-upload-pack']); + }); + + it('refuses a cross-origin redirect without ever contacting the destination', async () => { + // The destination is a fully working server: if the policy leaked, the + // chain would resolve successfully rather than merely failing, so a + // rejection here cannot be an accident of the target being broken. + const destination = await serve((_url, res) => ok(res)); + const source = await serve((url, res) => { + res.statusCode = 302; + res.setHeader('location', `${destination.origin}${url}`); + res.end(); + }); + + await expect( + resolveRedirectedRepoUrl({ + repoUrl: `${source.origin}/repo.git`, + hasToken: true, + reportHost: '127.0.0.1', + caPem: CA_PEM, + }), + ).rejects.toMatchObject({ transportFailure: true, reason: 'redirect-scope' }); + + expect(source.hits).toHaveLength(1); + expect(destination.hits).toHaveLength(0); + }); + + it('refuses a redirect that only changes the port, destination uncontacted', async () => { + const destination = await serve((_url, res) => ok(res)); + const source = await serve((url, res) => { + res.statusCode = 302; + res.setHeader('location', `https://127.0.0.1:${destination.port}${url}`); + res.end(); + }); + + await expect( + resolveRedirectedRepoUrl({ + repoUrl: `${source.origin}/repo.git`, + hasToken: false, + reportHost: '127.0.0.1', + caPem: CA_PEM, + }), + ).rejects.toMatchObject({ reason: 'redirect-scope' }); + expect(destination.hits).toHaveLength(0); + }); + + it('refuses a downgrade to plain http', async () => { + const source = await serve((_url, res) => { + res.statusCode = 302; + res.setHeader('location', 'http://127.0.0.1:9/repo.git/info/refs?service=git-upload-pack'); + res.end(); + }); + + await expect( + resolveRedirectedRepoUrl({ + repoUrl: `${source.origin}/repo.git`, + hasToken: true, + reportHost: '127.0.0.1', + caPem: CA_PEM, + }), + ).rejects.toMatchObject({ reason: 'redirect-scope' }); + }); + + it('refuses a chain longer than the hop cap instead of following it forever', async () => { + const source = await serve((url, res) => { + res.statusCode = 302; + res.setHeader('location', `${url}x`); + res.end(); + }); + + await expect( + resolveRedirectedRepoUrl({ + repoUrl: `${source.origin}/repo.git`, + hasToken: false, + reportHost: '127.0.0.1', + caPem: CA_PEM, + }), + ).rejects.toMatchObject({ reason: 'redirect-scope' }); + }); + + it('refuses a same-origin redirect that leaves the ref-advertise endpoint', async () => { + // Only the path prefix may move. A destination that no longer ends in + // /info/refs is not this repository relocating. + const source = await serve((_url, res) => { + res.statusCode = 302; + res.setHeader('location', '/somewhere/else'); + res.end(); + }); + + await expect( + resolveRedirectedRepoUrl({ + repoUrl: `${source.origin}/repo.git`, + hasToken: true, + reportHost: '127.0.0.1', + caPem: CA_PEM, + }), + ).rejects.toMatchObject({ reason: 'redirect-scope' }); + }); + + it('returns null when the source does not redirect, leaving git\'s own error intact', async () => { + const server = await serve((_url, res) => { + res.statusCode = 404; + res.end('nope'); + }); + + await expect( + resolveRedirectedRepoUrl({ + repoUrl: `${server.origin}/repo.git`, + hasToken: true, + reportHost: '127.0.0.1', + caPem: CA_PEM, + }), + ).resolves.toBeNull(); + }); + + it('recognises the stderr git actually emits for a refused redirect', () => { + // Pinned to git's real wording. git-remote-http reports a refused + // redirect as an HTTP error and never prints a Location header, which + // is why the destination has to be resolved by probing rather than by + // reading it out of stderr. If a git upgrade rephrases these, this + // fails here instead of silently making relocated repositories + // unreachable in production. + expect(looksLikeRedirectFailure( + "fatal: unable to access 'https://git.example.com/repo.git/': The requested URL returned error: 302", + )).toBe(true); + expect(looksLikeRedirectFailure('warning: redirecting to https://git.example.com/new.git/')).toBe(true); + expect(looksLikeRedirectFailure( + "fatal: unable to access 'https://git.example.com/repo.git/': The requested URL returned error: 404", + )).toBe(false); + expect(looksLikeRedirectFailure('fatal: Authentication failed')).toBe(false); + }); + + it('returns null when the probe itself cannot complete', async () => { + // Untrusted certificate: the chain cannot be proven safe, so no retry + // is authorised and the caller keeps git's original failure. + const server = await serve((_url, res) => { + res.statusCode = 302; + res.setHeader('location', '/other.git/info/refs?service=git-upload-pack'); + res.end(); + }); + + await expect( + resolveRedirectedRepoUrl({ + repoUrl: `${server.origin}/repo.git`, + hasToken: true, + reportHost: '127.0.0.1', + }), + ).resolves.toBeNull(); + }); +}); diff --git a/backend/src/__tests__/git-redirect.integration.test.ts b/backend/src/__tests__/git-redirect.integration.test.ts new file mode 100644 index 00000000..daa5877e --- /dev/null +++ b/backend/src/__tests__/git-redirect.integration.test.ts @@ -0,0 +1,256 @@ +/** + * Redirect behaviour end to end through real git against real TLS servers. + * + * The matrix pins both halves of the contract that the transport has to hold + * at once: a repository that relocates on its own host stays usable, and a + * redirect that leaves that host is refused without the destination being + * contacted or a credential being offered to it. + * + * Every fixture records the requests it receives, including the Authorization + * header, so credential scope and "never contacted" are asserted from what the + * servers actually observed rather than inferred from the thrown error. + */ +import { spawn, spawnSync } from 'child_process'; +import { promises as fs, readFileSync } from 'fs'; +import https from 'https'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { nativeGitTransport } from '../services/git/nativeGitTransport'; +import { buildBareRepo } from './__helpers__/gitFixture'; +import { requireGitBinary } from './__helpers__/externalDeps'; + +const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures'); +const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8'); +const TLS_OPTS = { + cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')), + key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')), +}; +const GOOD_TOKEN = 'correct-horse-battery-staple'; + +interface Request { url: string; authorization: string | null } + +interface Fixture { + origin: string; + requests: Request[]; + close: () => void; +} + +const openFixtures: Fixture[] = []; +const workspaces: string[] = []; + +/** + * A TLS git server. Paths under `/old.git/` are 302-redirected by `redirect` + * (returning an absolute URL, or null to serve normally); paths under + * `/new.git/` are served from the bare repo, behind Basic auth when + * `requireAuth` is set. + */ +function serveGit(opts: { + bareDir: string; + redirect?: (fixture: Fixture, url: string) => string | null; + requireAuth?: boolean; +}): Promise { + return new Promise((resolve, reject) => { + const requests: Request[] = []; + let self: Fixture; + const server = https.createServer(TLS_OPTS, (req, res) => { + const url = req.url ?? '/'; + requests.push({ url, authorization: req.headers.authorization ?? null }); + + if (url.startsWith('/old.git/') && opts.redirect) { + const target = opts.redirect(self, url); + if (target) { + res.statusCode = 302; + res.setHeader('location', target); + res.end(); + return; + } + } + if (!url.startsWith('/new.git/')) { + res.statusCode = 404; + res.end('unknown repo'); + return; + } + if (opts.requireAuth) { + const expected = `Basic ${Buffer.from(`x-access-token:${GOOD_TOKEN}`).toString('base64')}`; + if (req.headers.authorization !== expected) { + res.statusCode = 401; + res.setHeader('www-authenticate', 'Basic realm="git"'); + res.end('unauthorized'); + return; + } + } + const pathname = url.slice('/new.git'.length).split('?')[0]; + if (pathname === '/info/refs') { + const ps = spawn('git', ['upload-pack', '--stateless-rpc', '--advertise-refs', opts.bareDir]); + let out = Buffer.alloc(0); + ps.stdout.on('data', (d: Buffer) => { out = Buffer.concat([out, d]); }); + ps.on('close', (code) => { + if (code !== 0) { + res.statusCode = 500; + res.end('git upload-pack failed'); + return; + } + res.setHeader('content-type', 'application/x-git-upload-pack-advertisement'); + res.end(Buffer.concat([Buffer.from('001e# service=git-upload-pack\n0000'), out])); + }); + return; + } + if (pathname === '/git-upload-pack') { + const ps = spawn('git', ['upload-pack', '--stateless-rpc', opts.bareDir]); + res.setHeader('content-type', 'application/x-git-upload-pack-result'); + ps.stdout.pipe(res); + req.pipe(ps.stdin); + return; + } + res.statusCode = 404; + res.end('unsupported'); + }); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('fixture did not bind')); + return; + } + self = { + origin: `https://127.0.0.1:${address.port}`, + requests, + close: () => server.close(), + }; + openFixtures.push(self); + resolve(self); + }); + }); +} + +async function workspace(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-redir-ws-')); + workspaces.push(dir); + return dir; +} + +describe.skipIf(!requireGitBinary())('redirect destination revalidation (real git)', () => { + let bareDir: string; + let headSha: string; + let prevExtraCaCerts: string | undefined; + + beforeAll(async () => { + bareDir = buildBareRepo({ + srcPrefix: 'sencho-redir-src-', + barePrefix: 'sencho-redir-bare-', + userEmail: 'redir-test@sencho.test', + userName: 'Sencho Redirect Test', + }); + headSha = spawnSync('git', ['-C', bareDir, 'rev-parse', 'main'], { encoding: 'utf8' }) + .stdout.trim().toLowerCase(); + prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS; + delete process.env.NODE_EXTRA_CA_CERTS; + }); + + afterEach(async () => { + openFixtures.splice(0).forEach((f) => f.close()); + await Promise.all(workspaces.splice(0).map((d) => fs.rm(d, { recursive: true, force: true }))); + if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS; + else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts; + }); + + it('resolves a ref through an unauthenticated same-host redirect', async () => { + const server = await serveGit({ + bareDir, + redirect: (self, url) => `${self.origin}${url.replace('/old.git/', '/new.git/')}`, + }); + + const resolved = await nativeGitTransport.resolveRef({ + repoUrl: `${server.origin}/old.git`, + ref: 'main', + caBundlePem: CA_PEM, + workspaceRoot: await workspace(), + timeoutMs: 30_000, + }); + + expect(resolved).toEqual({ commitSha: headSha, kind: 'branch' }); + }); + + it('resolves a ref through an authenticated same-host redirect and sends the token to the relocated path', async () => { + const server = await serveGit({ + bareDir, + requireAuth: true, + redirect: (self, url) => `${self.origin}${url.replace('/old.git/', '/new.git/')}`, + }); + + const resolved = await nativeGitTransport.resolveRef({ + repoUrl: `${server.origin}/old.git`, + ref: 'main', + token: GOOD_TOKEN, + caBundlePem: CA_PEM, + workspaceRoot: await workspace(), + timeoutMs: 30_000, + }); + + expect(resolved).toEqual({ commitSha: headSha, kind: 'branch' }); + // The credential is scoped to the host, not to the original path, so + // the relocated endpoint on that same host must have received it. + const authorized = server.requests.filter( + (r) => r.url.startsWith('/new.git/') && r.authorization !== null, + ); + expect(authorized.length).toBeGreaterThan(0); + }); + + it('reports an authentication failure, not a redirect failure, when the token is wrong behind a same-host redirect', async () => { + const server = await serveGit({ + bareDir, + requireAuth: true, + redirect: (self, url) => `${self.origin}${url.replace('/old.git/', '/new.git/')}`, + }); + + await expect( + nativeGitTransport.resolveRef({ + repoUrl: `${server.origin}/old.git`, + ref: 'main', + token: 'not-the-right-token', + caBundlePem: CA_PEM, + workspaceRoot: await workspace(), + timeoutMs: 30_000, + }), + ).rejects.toMatchObject({ transportFailure: true, reason: 'exit' }); + }); + + it('refuses a cross-host redirect without contacting the destination or offering it the token', async () => { + // The destination is a fully working repository server. If the policy + // leaked, this fetch would SUCCEED, so the rejection below cannot be an + // artefact of a target that was broken anyway. + const destination = await serveGit({ bareDir }); + const source = await serveGit({ + bareDir, + redirect: (_self, url) => `${destination.origin}${url.replace('/old.git/', '/new.git/')}`, + }); + + await expect( + nativeGitTransport.resolveRef({ + repoUrl: `${source.origin}/old.git`, + ref: 'main', + token: 'sensitive-pat-do-not-leak', + caBundlePem: CA_PEM, + workspaceRoot: await workspace(), + timeoutMs: 30_000, + }), + ).rejects.toMatchObject({ transportFailure: true, reason: 'redirect-scope' }); + + // Settle, so a late request would still be counted rather than raced past. + await new Promise((r) => setTimeout(r, 250)); + expect(destination.requests).toHaveLength(0); + }); + + it('proves the cross-host destination would otherwise serve the same ref', async () => { + const destination = await serveGit({ bareDir }); + const resolved = await nativeGitTransport.resolveRef({ + repoUrl: `${destination.origin}/new.git`, + ref: 'main', + caBundlePem: CA_PEM, + workspaceRoot: await workspace(), + timeoutMs: 30_000, + }); + expect(resolved.commitSha).toBe(headSha); + }); +}); diff --git a/backend/src/__tests__/git-source-apply-recovery.test.ts b/backend/src/__tests__/git-source-apply-recovery.test.ts index 3f080b7b..706339d0 100644 --- a/backend/src/__tests__/git-source-apply-recovery.test.ts +++ b/backend/src/__tests__/git-source-apply-recovery.test.ts @@ -13,6 +13,38 @@ const mockMarkReconciling = vi.fn().mockReturnValue(true); const mockMarkImmediateVerified = vi.fn().mockReturnValue(true); const mockGet = vi.fn(); const mockCompensate = vi.fn(); +const mockGitOpsApplication = { + id: 'gitops-app', + lifecycle_status: 'active', + stack_name: 'app', + candidate_generation_id: null, +}; +const mockGitOpsStore = { + getLiveDirectApplication: vi.fn().mockReturnValue(mockGitOpsApplication), + getApplication: vi.fn().mockReturnValue(mockGitOpsApplication), + getGeneration: vi.fn().mockReturnValue(undefined), + getSettledAttempt: vi.fn().mockReturnValue(undefined), +}; +const mockGitOpsTransitions = { + allocateReconcileAttempt: vi.fn().mockReturnValue({ operationId: 'gitops-app:attempt:1', reserved: true }), + settleReconcileAttempt: vi.fn().mockReturnValue({ settled: true }), +}; + +vi.mock('../services/gitops/store', () => ({ + GitOpsStore: { + getInstance: () => mockGitOpsStore, + }, +})); + +vi.mock('../services/gitops/transitions', async () => { + const actual = await vi.importActual('../services/gitops/transitions'); + return { + ...actual, + GitOpsTransitions: { + getInstance: () => mockGitOpsTransitions, + }, + }; +}); vi.mock('../services/StackUpdateRecoveryService', () => ({ StackUpdateRecoveryService: { @@ -129,10 +161,6 @@ vi.mock('../services/DatabaseService', () => ({ setGitSourceLastPlan: mockSetGitSourceLastPlan, addNotificationHistory: mockAddNotificationHistory, getStackProjectEnvFiles: vi.fn().mockReturnValue([]), - // The apply path now asks whether this stack has a GitOps application. - // These fixtures predate the revision-state model, so the lookup finds - // nothing and every GitOps producer stays a no-op, which is exactly the - // behavior an install with pre-existing Git stacks gets. getDb: () => ({ prepare: () => ({ get: () => undefined, all: () => [], run: () => ({ changes: 0 }) }), transaction: (fn: () => unknown) => () => fn(), @@ -197,7 +225,7 @@ describe('git-source apply recovery (R1)', () => { v: 4, files: { 'compose.yaml': 'services:\n web:\n image: nginx\n' }, contextDir: null, - candidateRelPath: 'generations/cand', + candidateRelPath: 'generations/candidate-abc1234deadbeef', inventory: { inputs: [], refusals: [], @@ -246,7 +274,7 @@ describe('git-source apply recovery (R1)', () => { version: 4, files: [{ path: 'compose.yaml', content: 'services:\n web:\n image: nginx\n' }], contextDir: null, - candidateRelPath: 'generations/cand', + candidateRelPath: 'generations/candidate-abc1234deadbeef', inventory: { inputs: [], refusals: [], buildContexts: [] }, planFingerprint: 'fp-test', planSchemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION, diff --git a/backend/src/__tests__/git-source-http.test.ts b/backend/src/__tests__/git-source-http.test.ts index 58c5d8d3..c0fbef53 100644 --- a/backend/src/__tests__/git-source-http.test.ts +++ b/backend/src/__tests__/git-source-http.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect, vi } from 'vitest'; import type { Response } from 'express'; import { gitSourceStatus, sendGitSourceError, webhookPullStatus } from '../utils/gitSourceHttp'; -import { GitSourceError } from '../services/GitSourceService'; +import { GitSourceError, type GitSourceErrorCode } from '../services/GitSourceService'; describe('gitSourceStatus', () => { it('maps AUTH_FAILED to 400, never 401', () => { @@ -36,6 +36,10 @@ describe('gitSourceStatus', () => { expect(gitSourceStatus('NETWORK_TIMEOUT')).toBe(504); }); + it('maps RATE_LIMITED to 429, not to the auth or generic status', () => { + expect(gitSourceStatus('RATE_LIMITED')).toBe(429); + }); + it('maps PLAN_FINGERPRINT_REQUIRED to 400', () => { expect(gitSourceStatus('PLAN_FINGERPRINT_REQUIRED')).toBe(400); }); @@ -47,9 +51,25 @@ describe('gitSourceStatus', () => { expect(gitSourceStatus('PLAN_UNAVAILABLE')).toBe(409); }); - it('maps unknown codes to 400', () => { + it('maps GIT_ERROR to 400', () => { expect(gitSourceStatus('GIT_ERROR')).toBe(400); }); + + it('maps OPERATION_IN_FLIGHT to 409', () => { + expect(gitSourceStatus('OPERATION_IN_FLIGHT')).toBe(409); + }); + + it('has exactly one explicit mapping for every GitSourceErrorCode', () => { + const codes: GitSourceErrorCode[] = [ + 'REPO_NOT_FOUND', 'AUTH_FAILED', 'REF_NOT_FOUND', 'REF_DELETED', 'UNSUPPORTED_REF', + 'SSH_HOST_KEY_FAILED', 'FILE_NOT_FOUND', 'RATE_LIMITED', 'NETWORK_TIMEOUT', 'GIT_ERROR', 'STALE_PLAN', + 'PLAN_FINGERPRINT_REQUIRED', 'PLAN_BLOCKED', 'LEGACY_PENDING', 'PLAN_UNAVAILABLE', + 'OPERATION_IN_FLIGHT', + ]; + for (const code of codes) { + expect(typeof gitSourceStatus(code)).toBe('number'); + } + }); }); describe('webhookPullStatus', () => { diff --git a/backend/src/__tests__/git-source-routes.test.ts b/backend/src/__tests__/git-source-routes.test.ts index 18bafc12..5de0ccc8 100644 --- a/backend/src/__tests__/git-source-routes.test.ts +++ b/backend/src/__tests__/git-source-routes.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; -import fs from 'fs'; +import fs, { readFileSync } from 'fs'; import path from 'path'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; import { REF_MAX_LEN } from '../services/git/nativeGitTransport'; @@ -26,70 +26,13 @@ import { ComposeService } from '../services/ComposeService'; import { GitSourceService, GitSourceError } from '../services/GitSourceService'; import { GitOpsStore } from '../services/gitops/store'; import { GitOpsTransitions } from '../services/gitops/transitions'; +import { deliveryKey } from '../services/gitops/triggers'; import { insertHistory } from '../services/gitops/history'; import type { GitOpsApplicationRow } from '../services/gitops/types'; import { PROXY_DEPLOY_ACTOR_HEADER, PROXY_DEPLOY_SOURCE_HEADER } from '../services/license-headers'; - -/** A minimal live Direct application row for GitOps read-path fixtures. */ -function directApplicationFixture(id: string, stackName: string): GitOpsApplicationRow { - return { - id, - lifecycle_key: `direct:${stackName}`, - lifecycle_status: 'active', - target_mode: 'direct', - stack_name: stackName, - blueprint_id: null, - configured_repo_url: 'https://github.com/example/repo.git', - repo_identity_json: '{"host":"github.com","pathname":"/example/repo.git"}', - configured_ref: 'main', - compose_paths_json: '["compose.yaml"]', - context_dir: null, - sync_env: 0, - env_path: null, - materialization_fingerprint: 'a'.repeat(64), - desired_commit_sha: null, - fetched_commit_sha: null, - fetched_resolved_ref_kind: null, - candidate_generation_id: null, - accepted_generation_id: null, - candidate_plan_blocked: 0, - review_required: 0, - artifact_set_id: null, - latest_artifact_set_id: null, - intent_revision_id: null, - rollout_candidate_id: null, - rollout_generation_id: null, - source_acceptance_ref: null, - placement_approval_ref: null, - rollout_authorization_ref: null, - legacy_combined_approval_ref: null, - preflight_fingerprint: null, - latest_operation_id: null, - active_operation_id: null, - active_operation_stage: null, - active_operation_at: null, - active_generation_id: null, - pause_at: null, - pause_reason: null, - partial_json: null, - failure_stage: null, - failure_class: null, - failure_at: null, - retry_at: null, - retry_count: 0, - suspended_at: null, - recovery_ref: null, - recovery_phase: null, - interruption_stage: null, - interruption_at: null, - interruption_operation_id: null, - interruption_generation_id: null, - evidence_fresh_at: null, - evidence_limitations_json: null, - created_at: 1, - updated_at: 1, - }; -} +import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets'; +import { directApplicationFixture } from './helpers/gitopsFixtures'; +import { ROLE_PERMISSIONS } from '../middleware/permissions'; // ── Hoisted mocks (must come before importing the app) ───────────────── @@ -114,6 +57,7 @@ function seedGitSource(stackName: string): void { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -133,8 +77,17 @@ function adminToken(): string { return jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' }); } +function viewerToken(): string { + return jwt.sign({ username: 'viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' }); +} + +function nodeAdminToken(): string { + return jwt.sign({ username: 'node-admin', role: 'node-admin' }, TEST_JWT_SECRET, { expiresIn: '1m' }); +} + beforeAll(async () => { tmpDir = await setupTestDb(); + DatabaseService.getInstance().addUser({ username: 'node-admin', password_hash: 'test', role: 'node-admin' }); ({ app } = await import('../index')); // Seed a real stack directory so the PUT handler's existence guard is satisfied @@ -218,6 +171,20 @@ describe('POST /api/git-sources/browse: URL validation', () => { expect(listRepoTree).not.toHaveBeenCalled(); listRepoTree.mockRestore(); }); + + it('rejects repository hosts that resolve to an unsafe address', async () => { + const res = await withLoopbackTargetProtection(() => request(app) + .post('/api/git-sources/browse') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + repo_url: 'https://127.0.0.1:9999/repo.git', + branch: 'main', + auth_type: 'none', + })); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not allowed/i); + }); }); describe('PUT /api/stacks/:stackName/git-source — max-length caps', () => { @@ -690,6 +657,253 @@ describe('POST /api/stacks/:stackName/git-source/webhook-pull status codes', () expect(res.status).toBe(200); pullSpy.mockRestore(); }); + + it('passes a remote webhook delivery id into the durable pull path', async () => { + seedGitSource('webhook-delivery-id'); + const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull') + .mockResolvedValue({ status: 'success', message: 'Pending update ready at abc1234.' }); + const res = await request(app) + .post('/api/stacks/webhook-delivery-id/git-source/webhook-pull') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ deliveryId: 'webhook:42:provider-delivery-1' }); + + expect(res.status).toBe(200); + expect(pullSpy).toHaveBeenCalledWith('webhook-delivery-id', true, 'webhook:42:provider-delivery-1'); + pullSpy.mockRestore(); + }); + + it('requires stack:deploy when a redelivery carries a persisted deploy intent', async () => { + const stackName = 'webhook-delivery-deploy-auth'; + const applicationId = 'webhook-delivery-deploy-auth-app'; + const deliveryId = 'webhook:control:42:provider-delivery-deploy'; + seedGitSource(stackName); + GitOpsStore.getInstance().insertApplication(directApplicationFixture(applicationId, stackName)); + GitOpsTransitions.getInstance().reserveReconcileAttempt( + applicationId, + { + operationId: deliveryKey('webhook', 'fetch', deliveryId), + actor: 'system:webhook', + trigger: 'webhook', + at: Date.now(), + }, + undefined, + { autoApply: true, deploy: true }, + ); + const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull'); + const originalPermissions = ROLE_PERMISSIONS['node-admin']; + ROLE_PERMISSIONS['node-admin'] = originalPermissions.filter((permission) => permission !== 'stack:deploy'); + + try { + const res = await request(app) + .post(`/api/stacks/${stackName}/git-source/webhook-pull`) + .set('Authorization', `Bearer ${nodeAdminToken()}`) + .send({ deliveryId }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + expect(pullSpy).not.toHaveBeenCalled(); + } finally { + ROLE_PERMISSIONS['node-admin'] = originalPermissions; + pullSpy.mockRestore(); + } + }); + + it('requires stack:deploy when a first delivery is configured to auto-apply and deploy', async () => { + const stackName = 'webhook-first-delivery-deploy-auth'; + seedGitSource(stackName); + DatabaseService.getInstance().getDb() + .prepare('UPDATE stack_git_sources SET auto_apply_on_webhook = 1, auto_deploy_on_apply = 1 WHERE stack_name = ?') + .run(stackName); + const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull'); + const originalPermissions = ROLE_PERMISSIONS['node-admin']; + ROLE_PERMISSIONS['node-admin'] = originalPermissions.filter((permission) => permission !== 'stack:deploy'); + + try { + const res = await request(app) + .post(`/api/stacks/${stackName}/git-source/webhook-pull`) + .set('Authorization', `Bearer ${nodeAdminToken()}`); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + expect(pullSpy).not.toHaveBeenCalled(); + } finally { + ROLE_PERMISSIONS['node-admin'] = originalPermissions; + pullSpy.mockRestore(); + } + }); + + it.each([ + ['an object', { nested: true }], + ['a blank string', ' '], + ['a string over 512 characters', 'x'.repeat(513)], + ])('rejects %s as a remote webhook delivery id', async (_caseName, deliveryId) => { + seedGitSource('webhook-delivery-id-invalid'); + const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull'); + + try { + const res = await request(app) + .post('/api/stacks/webhook-delivery-id-invalid/git-source/webhook-pull') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ deliveryId }); + + expect(res.status).toBe(400); + expect(pullSpy).not.toHaveBeenCalled(); + } finally { + pullSpy.mockRestore(); + } + }); +}); + +describe('POST /api/stacks/:stackName/git-source/suspend', () => { + it('returns 401 without auth', async () => { + const res = await request(app).post('/api/stacks/existing-stack/git-source/suspend'); + expect(res.status).toBe(401); + }); + + it('returns 400 for an invalid stack name', async () => { + const res = await request(app) + .post('/api/stacks/..%2fescape/git-source/suspend') + .set('Authorization', `Bearer ${adminToken()}`) + .send({}); + expect([400, 404]).toContain(res.status); + }); + + it('passes the reason through and returns the normalized result', async () => { + const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend') + .mockResolvedValue({ outcome: 'suspended', reason: 'Reconciliation is suspended: maintenance', nextAction: 'resume' }); + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/suspend') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ reason: 'maintenance' }); + expect(res.status).toBe(200); + expect(res.body.outcome).toBe('suspended'); + expect(suspendSpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ reason: 'maintenance' })); + suspendSpy.mockRestore(); + }); + + it('omits reason when none is given in the body', async () => { + const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend') + .mockResolvedValue({ outcome: 'suspended', reason: 'Reconciliation is suspended.', nextAction: 'resume' }); + await request(app) + .post('/api/stacks/existing-stack/git-source/suspend') + .set('Authorization', `Bearer ${adminToken()}`) + .send({}); + expect(suspendSpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ reason: undefined })); + suspendSpy.mockRestore(); + }); + + it('maps a refused suspend to 409', async () => { + const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend') + .mockRejectedValue(new GitSourceError('OPERATION_IN_FLIGHT', 'Cannot suspend existing-stack: source is not live')); + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/suspend') + .set('Authorization', `Bearer ${adminToken()}`) + .send({}); + expect(res.status).toBe(409); + suspendSpy.mockRestore(); + }); + + it('denies without the stack:edit permission', async () => { + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/suspend') + .set('Authorization', `Bearer ${viewerToken()}`) + .send({}); + expect([401, 403]).toContain(res.status); + }); + + it('rejects an oversized reason with 400', async () => { + const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend'); + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/suspend') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ reason: 'x'.repeat(513) }); + expect(res.status).toBe(400); + expect(suspendSpy).not.toHaveBeenCalled(); + suspendSpy.mockRestore(); + }); +}); + +describe('POST /api/stacks/:stackName/git-source/resume', () => { + it('returns 401 without auth', async () => { + const res = await request(app).post('/api/stacks/existing-stack/git-source/resume'); + expect(res.status).toBe(401); + }); + + it('returns 400 for an invalid stack name', async () => { + const res = await request(app) + .post('/api/stacks/..%2fescape/git-source/resume') + .set('Authorization', `Bearer ${adminToken()}`) + .send({}); + expect([400, 404]).toContain(res.status); + }); + + it('returns the normalized result', async () => { + const resumeSpy = vi.spyOn(GitSourceService.getInstance(), 'resume') + .mockResolvedValue({ outcome: 'no_source_change', reason: 'ok', nextAction: 'none' }); + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/resume') + .set('Authorization', `Bearer ${adminToken()}`) + .send({}); + expect(res.status).toBe(200); + expect(res.body.outcome).toBe('no_source_change'); + expect(resumeSpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ actor: expect.any(String) })); + resumeSpy.mockRestore(); + }); + + it('denies without the stack:edit permission', async () => { + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/resume') + .set('Authorization', `Bearer ${viewerToken()}`) + .send({}); + expect([401, 403]).toContain(res.status); + }); +}); + +describe('POST /api/stacks/:stackName/git-source/retry', () => { + it('returns 401 without auth', async () => { + const res = await request(app).post('/api/stacks/existing-stack/git-source/retry'); + expect(res.status).toBe(401); + }); + + it('returns 400 for an invalid stack name', async () => { + const res = await request(app) + .post('/api/stacks/..%2fescape/git-source/retry') + .set('Authorization', `Bearer ${adminToken()}`) + .send({}); + expect([400, 404]).toContain(res.status); + }); + + it('returns the normalized result', async () => { + const retrySpy = vi.spyOn(GitSourceService.getInstance(), 'retry') + .mockResolvedValue({ outcome: 'candidate_already_fetched', reason: 'ok', nextAction: 'none' }); + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/retry') + .set('Authorization', `Bearer ${adminToken()}`) + .send({}); + expect(res.status).toBe(200); + expect(res.body.outcome).toBe('candidate_already_fetched'); + expect(retrySpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ actor: expect.any(String) })); + retrySpy.mockRestore(); + }); + + it('maps an unexpected failure to 500', async () => { + const retrySpy = vi.spyOn(GitSourceService.getInstance(), 'retry') + .mockRejectedValue(new Error('unexpected')); + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/retry') + .set('Authorization', `Bearer ${adminToken()}`) + .send({}); + expect(res.status).toBe(500); + retrySpy.mockRestore(); + }); + + it('denies without the stack:edit permission', async () => { + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/retry') + .set('Authorization', `Bearer ${viewerToken()}`) + .send({}); + expect([401, 403]).toContain(res.status); + }); }); describe('DELETE /api/stacks/:stackName/git-source, detach/export contract', () => { @@ -1162,6 +1376,7 @@ describe('stack_git_sources manifest cache columns', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -1755,6 +1970,15 @@ describe('POST /api/git-sources/ssh-host-key', () => { expect(res.body.error).toMatch(/SSH repository URL/i); }); + it('rejects host-key probes to an unsafe address', async () => { + const res = await withLoopbackTargetProtection(() => request(app) + .post('/api/git-sources/ssh-host-key') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ repo_url: 'ssh://git@127.0.0.1:22/example/repo.git' })); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not allowed/i); + }); + it('returns scanned host keys for an SSH repository URL', async () => { const scanHostKeys = vi.spyOn( await import('../services/git/sshTrust'), @@ -1769,12 +1993,13 @@ describe('POST /api/git-sources/ssh-host-key', () => { const res = await request(app) .post('/api/git-sources/ssh-host-key') .set('Authorization', `Bearer ${adminToken()}`) - .send({ repo_url: 'git@github.com:example/repo.git' }); + .send({ repo_url: 'git@pinned.example:example/repo.git' }); expect(res.status).toBe(200); - expect(res.body.host).toBe('github.com'); + expect(res.body.host).toBe('pinned.example'); expect(res.body.port).toBe(22); expect(res.body.keys).toHaveLength(1); expect(res.body.keys[0].fingerprint).toBe('SHA256:fixtureFingerprint'); + expect(scanHostKeys).toHaveBeenCalledWith('pinned.example', 22, '93.184.216.34'); scanHostKeys.mockRestore(); }); @@ -1820,7 +2045,7 @@ describe('POST /api/git-sources/ssh-host-key', () => { const res = await request(app) .post('/api/git-sources/ssh-host-key') .set('Authorization', `Bearer ${adminToken()}`) - .send({ repo_url: 'ssh://git@git.example.com:2222/org/repo.git' }); + .send({ repo_url: 'ssh://git@github.com:2222/org/repo.git' }); expect(res.status).toBe(500); expect(res.body.error).toMatch(/Git source operation failed/i); scanHostKeys.mockRestore(); @@ -1952,6 +2177,7 @@ describe('SSH deploy-key route validation', () => { encrypted_deploy_key: CryptoService.getInstance().encrypt(deployKey), ssh_known_hosts_entry: knownHosts, ssh_host_key_fingerprint: 'SHA256:fixtureFingerprint', + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -1974,4 +2200,90 @@ describe('SSH deploy-key route validation', () => { expect(serialized).not.toContain(deployKey); expect(serialized).not.toContain('encrypted_deploy_key'); }); + + it('PUT stores a custom CA bundle and GET exposes has_ca_bundle without returning PEM', async () => { + const stackName = 'https-ca-stack'; + const composeDir = process.env.COMPOSE_DIR!; + fs.mkdirSync(path.join(composeDir, stackName), { recursive: true }); + fs.writeFileSync(path.join(composeDir, stackName, 'compose.yaml'), 'services:\n x:\n image: nginx\n'); + const pem = readFileSync(path.join(process.cwd(), '..', 'e2e', 'fixtures', 'git-ca.pem'), 'utf8'); + const fetchFromGit = vi.spyOn(GitSourceService.getInstance(), 'fetchFromGit') + .mockResolvedValue({ + composeFiles: [{ path: 'compose.yaml', content: 'services:\n x:\n image: nginx\n' }], + envContent: null, + commitSha: 'a'.repeat(40), + resolvedRefKind: 'branch', + warnings: [], + }); + const res = await request(app) + .put(`/api/stacks/${stackName}/git-source`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + repo_url: 'https://git.example.com/org/repo.git', + branch: 'main', + compose_paths: ['compose.yaml'], + auth_type: 'none', + ca_bundle: pem, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + }); + expect(res.status).toBe(200); + expect(res.body.has_ca_bundle).toBe(true); + expect(JSON.stringify(res.body)).not.toContain('BEGIN CERTIFICATE'); + fetchFromGit.mockRestore(); + }); + + it('PUT with remove_ca_bundle=true clears a previously stored CA bundle', async () => { + const stackName = 'https-ca-revoke-stack'; + const composeDir = process.env.COMPOSE_DIR!; + fs.mkdirSync(path.join(composeDir, stackName), { recursive: true }); + fs.writeFileSync(path.join(composeDir, stackName, 'compose.yaml'), 'services:\n x:\n image: nginx\n'); + const pem = readFileSync(path.join(process.cwd(), '..', 'e2e', 'fixtures', 'git-ca.pem'), 'utf8'); + const fetchFromGit = vi.spyOn(GitSourceService.getInstance(), 'fetchFromGit') + .mockResolvedValue({ + composeFiles: [{ path: 'compose.yaml', content: 'services:\n x:\n image: nginx\n' }], + envContent: null, + commitSha: 'b'.repeat(40), + resolvedRefKind: 'branch', + warnings: [], + }); + // Step 1: store a CA bundle. + const storeRes = await request(app) + .put(`/api/stacks/${stackName}/git-source`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + repo_url: 'https://git.example.com/org/repo.git', + branch: 'main', + compose_paths: ['compose.yaml'], + auth_type: 'none', + ca_bundle: pem, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + }); + expect(storeRes.status).toBe(200); + expect(storeRes.body.has_ca_bundle).toBe(true); + // Step 2: explicit removal (textarea left empty, UI sets the flag). + const revokeRes = await request(app) + .put(`/api/stacks/${stackName}/git-source`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + repo_url: 'https://git.example.com/org/repo.git', + branch: 'main', + compose_paths: ['compose.yaml'], + auth_type: 'none', + remove_ca_bundle: true, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + }); + expect(revokeRes.status).toBe(200); + expect(revokeRes.body.has_ca_bundle).toBe(false); + expect(JSON.stringify(revokeRes.body)).not.toContain('BEGIN CERTIFICATE'); + // Step 3: GET should confirm the row no longer carries a CA bundle. + const getRes = await request(app) + .get(`/api/stacks/${stackName}/git-source`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(getRes.status).toBe(200); + expect(getRes.body.has_ca_bundle).toBe(false); + fetchFromGit.mockRestore(); + }); }); diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index 1236005f..5e91609d 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -21,10 +21,13 @@ import type { TransportFailure } from '../services/git/errors'; import { GitOpsStore } from '../services/gitops/store'; import { GitOpsTransitions } from '../services/gitops/transitions'; import { StackOpLockService } from '../services/StackOpLockService'; +import { coalesceKey, deliveryKey, type ReconcileRequest, type ReconcileTrigger } from '../services/gitops/triggers'; import { + buildDirectApplicationRow, buildGenerationRow, directSourceIdentity, newGitOpsId, + stackManagedRoot, type DirectSourceConfig, } from '../services/gitops/directApplication'; @@ -70,6 +73,11 @@ const { mockRecoveryLinkGateOrRetain: vi.fn(), })); +const { mockInvalidateNodeCaches, mockTriggerPostDeployScan } = vi.hoisted(() => ({ + mockInvalidateNodeCaches: vi.fn(), + mockTriggerPostDeployScan: vi.fn(async () => undefined), +})); + vi.mock('../services/StackUpdateRecoveryService', () => ({ StackUpdateRecoveryService: { getInstance: () => ({ @@ -86,6 +94,20 @@ vi.mock('../services/StackUpdateRecoveryService', () => ({ }, })); +vi.mock('../helpers/cacheInvalidation', async () => { + const actual = await vi.importActual( + '../helpers/cacheInvalidation', + ); + return { ...actual, invalidateNodeCaches: mockInvalidateNodeCaches }; +}); + +vi.mock('../helpers/policyGate', async () => { + const actual = await vi.importActual( + '../helpers/policyGate', + ); + return { ...actual, triggerPostDeployScan: mockTriggerPostDeployScan }; +}); + let tmpDir: string; let GitSourceService: typeof import('../services/GitSourceService').GitSourceService; @@ -220,6 +242,41 @@ function mockSuccessfulClone(options: { return sha; } +/** + * Configure a plain single-file Git source for a stack, without creating the + * stack itself. The caller stages the clone mock first: upsert runs a + * reachability fetch. + */ +async function configureGitSource(stackName: string): Promise { + await GitSourceService.getInstance().upsert({ + stackName, + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); +} + +/** Operation ids of every gitops_history row an application recorded at one stage. */ +function historyOperationIds(applicationId: string, stage: string): string[] { + const rows = DatabaseService.getInstance().getDb() + .prepare('SELECT operation_id FROM gitops_history WHERE application_id = ? AND stage = ?') + .all(applicationId, stage) as { operation_id: string }[]; + return rows.map((r) => r.operation_id); +} + +/** Settled attempt rows for one application, used to compare follower results. */ +function settledAttemptsForApplication(applicationId: string): { operation_id: string; after_json: string }[] { + return DatabaseService.getInstance().getDb() + .prepare("SELECT operation_id, after_json FROM gitops_history WHERE application_id = ? AND stage = 'source_reconcile_settled'") + .all(applicationId) as { operation_id: string; after_json: string }[]; +} + /** Wrap a single compose string in the ComposeFile[] shape the new APIs take. */ function asFiles(content: string): import('../services/GitSourceService').ComposeFile[] { return [{ path: 'compose.yaml', content }]; @@ -239,6 +296,27 @@ async function cleanupStackDir(name: string) { const SKIP_PLAN_FINGERPRINT = { requirePlanFingerprint: false as const }; +describe('GitSourceService.shortOperationId', () => { + function shortOperationId(operationId: string): string { + return (GitSourceService as unknown as { shortOperationId: (id: string) => string }).shortOperationId(operationId); + } + + it('discriminates between reserved attempts on the same application, unlike a fixed-width prefix', () => { + const appId = '4457ddc3-3eb0-444e-902c-7e65d355b36b'; + expect(shortOperationId(`${appId}:attempt:1`)).toBe('1'); + expect(shortOperationId(`${appId}:attempt:2`)).toBe('2'); + }); + + it('uses the delivery-key suffix for a webhook-triggered reservation', () => { + expect(shortOperationId('webhook:fetch:delivery-abc')).toBe('delivery-abc'); + }); + + it('falls back to a prefix for a plain UUID with no colon', () => { + const uuid = '29ec01cd-4129-4c4c-a5f2-4e2368f44490'; + expect(shortOperationId(uuid)).toBe(uuid.slice(0, 8)); + }); +}); + describe('GitSourceService.hashContent', () => { it('produces stable hashes for identical inputs', () => { const svc = GitSourceService.getInstance(); @@ -409,6 +487,137 @@ describe('GitSourceService.upsert (encryption + reachability)', () => { expect(row?.encrypted_token).not.toBe('ghp_secret_token_value'); }); + it('stores an encrypted CA bundle and exposes has_ca_bundle without leaking PEM', async () => { + mockSuccessfulClone(); + const svc = GitSourceService.getInstance(); + const pem = '-----BEGIN CERTIFICATE-----\nTEST-CA-PEM\n-----END CERTIFICATE-----\n'; + const created = await svc.upsert({ + stackName: 'ca-stack', + repoUrl: 'https://git.example.com/org/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + caBundle: pem, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + expect(created.has_ca_bundle).toBe(true); + expect(JSON.stringify(created)).not.toContain('TEST-CA-PEM'); + const row = DatabaseService.getInstance().getGitSource('ca-stack'); + expect(row?.encrypted_ca_bundle).toBeTruthy(); + expect(row?.encrypted_ca_bundle).not.toBe(pem); + }); + + it('explicitly removes a stored CA bundle when removeCaBundle is true, even when caBundle is omitted', async () => { + mockSuccessfulClone(); + const svc = GitSourceService.getInstance(); + const pem = '-----BEGIN CERTIFICATE-----\nTEST-CA-PEM\n-----END CERTIFICATE-----\n'; + // Step 1: store a CA bundle. + await svc.upsert({ + stackName: 'ca-revoke-stack', + repoUrl: 'https://git.example.com/org/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + caBundle: pem, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + let row = DatabaseService.getInstance().getGitSource('ca-revoke-stack'); + expect(row?.encrypted_ca_bundle).toBeTruthy(); + // Step 2: simulate the operator clicking "Remove stored CA": the + // textarea is left empty and the UI sends removeCaBundle: true with + // caBundle omitted. The stored CA must be cleared. + const updated = await svc.upsert({ + stackName: 'ca-revoke-stack', + repoUrl: 'https://git.example.com/org/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + removeCaBundle: true, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + expect(updated.has_ca_bundle).toBe(false); + expect(JSON.stringify(updated)).not.toContain('TEST-CA-PEM'); + row = DatabaseService.getInstance().getGitSource('ca-revoke-stack'); + expect(row?.encrypted_ca_bundle).toBeNull(); + }); + + it('saves an explicit CA removal even when the repository is unreachable without that CA', async () => { + // The dry-run reachability check normally runs on every save. A + // repository that genuinely needs its CA to be reached would fail + // that check the instant the CA is removed, refusing the very + // request meant to retire it. removeCaBundle must bypass the + // check so the operator's explicit intent to remove always saves. + mockSuccessfulClone(); + const svc = GitSourceService.getInstance(); + const pem = '-----BEGIN CERTIFICATE-----\nTEST-CA-PEM\n-----END CERTIFICATE-----\n'; + mockResolveRef.mockImplementation(async (req: { caBundlePem?: string | null }) => { + if (!req.caBundlePem) { + throw gitFailure('unable to get local issuer certificate', false); + } + return { commitSha: 'a'.repeat(40), kind: 'branch' as const }; + }); + await svc.upsert({ + stackName: 'ca-required-stack', + repoUrl: 'https://git.example.com/org/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + caBundle: pem, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + + // Sanity check: without removeCaBundle, an upsert that can no longer + // reach the repository is refused, proving the dry-run check itself + // still runs for ordinary saves. + await expect(svc.upsert({ + stackName: 'ca-required-stack', + repoUrl: 'https://git.example.com/org/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + caBundle: null, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + })).rejects.toBeTruthy(); + + // The removal itself must still save. + const removed = await svc.upsert({ + stackName: 'ca-required-stack', + repoUrl: 'https://git.example.com/org/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + removeCaBundle: true, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + expect(removed.has_ca_bundle).toBe(false); + const row = DatabaseService.getInstance().getGitSource('ca-required-stack'); + expect(row?.encrypted_ca_bundle).toBeNull(); + }); + it('preserves an existing token when update omits token (undefined)', async () => { mockSuccessfulClone(); const svc = GitSourceService.getInstance(); @@ -1051,6 +1260,34 @@ describe('GitSourceService error mapping', () => { await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' }); }); + it('propagates the raw transport reason onto GitSourceError.extras for retry classification', async () => { + mockFetchAtCommit.mockRejectedValueOnce(gitFailure( + 'fatal: the remote end hung up unexpectedly', + false, + )); + try { + await svc().fetchFromGit(fetchParams); + expect.fail('should have thrown'); + } catch (e) { + expect((e as InstanceType).extras?.transportReason).toBe('exit'); + } + }); + + it('propagates a non-exit transport reason (e.g. timeout) without hardcoding to exit', async () => { + mockFetchAtCommit.mockRejectedValueOnce({ + transportFailure: true as const, + reason: 'timeout', + host: 'github.com', + hasToken: false, + } satisfies TransportFailure); + try { + await svc().fetchFromGit(fetchParams); + expect.fail('should have thrown'); + } catch (e) { + expect((e as InstanceType).extras?.transportReason).toBe('timeout'); + } + }); + it('maps a TLS certificate failure to a certificate GIT_ERROR', async () => { mockFetchAtCommit.mockRejectedValueOnce(gitFailure( "fatal: unable to access 'https://github.com/example/repo.git/': SSL certificate problem: self-signed certificate", @@ -1310,23 +1547,379 @@ describe('GitSourceService.handleWebhookPull debounce', () => { // Stamp a recent debounce timestamp directly DatabaseService.getInstance().touchGitSourceDebounce('debounce-stack'); - const result = await svc.handleWebhookPull('debounce-stack'); + const result = await svc.handleWebhookPull('debounce-stack', true); expect(result.status).toBe('skipped'); expect(result.message).toMatch(/rate limited/i); }); it('returns error when stack has no Git source configured', async () => { const svc = GitSourceService.getInstance(); - const result = await svc.handleWebhookPull('does-not-exist'); + const result = await svc.handleWebhookPull('does-not-exist', true); expect(result.status).toBe('error'); expect(result.message).toMatch(/no git source/i); }); + it('fails closed and does not clone when reservation itself fails', async () => { + mockSuccessfulClone({ sha: '2'.repeat(40) }); + const svc = GitSourceService.getInstance(); + await configureGitSource('webhook-reservation-fails-closed'); + mockGitClone.mockClear(); + const reserveSpy = vi.spyOn(GitOpsTransitions.prototype, 'allocateReconcileAttempt') + .mockImplementationOnce(() => { throw new Error('simulated reservation failure'); }); + + try { + const result = await svc.handleWebhookPull('webhook-reservation-fails-closed', true); + expect(result.status).toBe('error'); + expect(mockGitClone).not.toHaveBeenCalled(); + } finally { + reserveSpy.mockRestore(); + } + }); + + it('fails closed and does not clone when the application was detached but the source config survives', async () => { + mockSuccessfulClone({ sha: '3'.repeat(40) }); + const svc = GitSourceService.getInstance(); + await configureGitSource('webhook-tombstoned-app'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('webhook-tombstoned-app')!.id; + GitOpsTransitions.getInstance().applicationTombstoned(applicationId, 'detached', { + operationId: 'op-detach-3', actor: 'tester', trigger: 'test', at: Date.now(), + }); + mockGitClone.mockClear(); + + const result = await svc.handleWebhookPull('webhook-tombstoned-app', true); + + // Skipped, not error: this is a permanent state, and reporting it + // as a delivery failure on every future push risks the Git host + // disabling the webhook for a condition retrying can never fix. + expect(result.status).toBe('skipped'); + expect(result.message).toMatch(/GitOps tracking was removed/); + expect(mockGitClone).not.toHaveBeenCalled(); + }); + + it('fails closed and does not clone when the application was deleted but the source config survives', async () => { + mockSuccessfulClone({ sha: '4'.repeat(40) }); + const svc = GitSourceService.getInstance(); + await configureGitSource('webhook-deleted-app'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('webhook-deleted-app')!.id; + GitOpsTransitions.getInstance().applicationTombstoned(applicationId, 'deleted', { + operationId: 'op-delete-webhook', actor: 'tester', trigger: 'test', at: Date.now(), + }); + mockGitClone.mockClear(); + + const result = await svc.handleWebhookPull('webhook-deleted-app', true); + + expect(result.status).toBe('skipped'); + expect(result.message).toMatch(/GitOps tracking is unavailable/); + expect(mockGitClone).not.toHaveBeenCalled(); + }); + + it('does not execute a persisted deploy intent when the caller lacks deploy authorization', async () => { + const stackName = 'webhook-persisted-deploy-auth'; + const deliveryId = 'webhook:control:7:deploy-auth'; + mockSuccessfulClone({ sha: '5'.repeat(40) }); + const svc = GitSourceService.getInstance(); + await configureGitSource(stackName); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication(stackName)!.id; + GitOpsTransitions.getInstance().reserveReconcileAttempt( + applicationId, + { + operationId: deliveryKey('webhook', 'fetch', deliveryId), + actor: 'system:webhook', + trigger: 'webhook', + at: Date.now(), + }, + undefined, + { autoApply: true, deploy: true }, + ); + mockGitClone.mockClear(); + + const result = await svc.handleWebhookPull(stackName, false, deliveryId); + + expect(result.status).toBe('error'); + expect(result.message).toMatch(/deploy permission/i); + expect(mockGitClone).not.toHaveBeenCalled(); + }); + + it('does not require deploy authorization when auto-apply is disabled', async () => { + const stackName = 'webhook-fetch-only-deploy-setting'; + const deliveryId = 'delivery-fetch-only-deploy-setting'; + mockSuccessfulClone({ sha: '7'.repeat(40) }); + const svc = GitSourceService.getInstance(); + await svc.upsert({ + stackName, + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + DatabaseService.getInstance().getDb() + .prepare('UPDATE stack_git_sources SET auto_deploy_on_apply = 1 WHERE stack_name = ?') + .run(stackName); + mockGitClone.mockClear(); + + expect(svc.webhookDeliveryRequiresDeploy(stackName, deliveryId)).toBe(false); + const first = await svc.handleWebhookPull(stackName, false, deliveryId); + expect(first.status).toBe('success'); + expect(svc.webhookDeliveryRequiresDeploy(stackName, deliveryId)).toBe(false); + + DatabaseService.getInstance().getDb() + .prepare('UPDATE stack_git_sources SET last_debounce_at = ? WHERE stack_name = ?') + .run(Date.now() - 999_999, stackName); + mockGitClone.mockClear(); + const redelivery = await svc.handleWebhookPull(stackName, false, deliveryId); + + expect(redelivery.status).toBe('success'); + expect(mockGitClone).not.toHaveBeenCalled(); + }); + + it('fails closed on the auto-apply step alone: the fetch settles, the apply reservation fails, and no apply proceeds', async () => { + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: '6'.repeat(40) }); + const svc = GitSourceService.getInstance(); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + try { + await svc.upsert({ + stackName: 'webhook-apply-reservation-fails-closed', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: true, + autoDeployOnApply: false, + }); + } finally { + validateSpy.mockRestore(); + } + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('webhook-apply-reservation-fails-closed')!.id; + + // First call (the fetch) reserves normally; the second (the + // auto-apply) fails, isolating the apply-stage reservation path. + const originalReserve = GitOpsTransitions.prototype.reserveReconcileAttempt; + const reserveSpy = vi.spyOn(GitOpsTransitions.prototype, 'reserveReconcileAttempt') + .mockImplementationOnce(function (this: GitOpsTransitions, ...args: Parameters) { + return originalReserve.apply(this, args); + }) + .mockImplementationOnce(() => { throw new Error('simulated apply reservation failure'); }); + + try { + const result = await svc.handleWebhookPull( + 'webhook-apply-reservation-fails-closed', + true, + 'delivery-apply-reservation-failure', + ); + expect(result.status).toBe('error'); + expect(saveSpy).not.toHaveBeenCalled(); + // The fetch attempt settled normally; only the apply attempt + // never got as far as being reserved at all. + const unsettled = GitOpsStore.getInstance().listUnsettledReconcileAttempts() + .filter((r) => r.application_id === applicationId); + expect(unsettled).toHaveLength(0); + } finally { + reserveSpy.mockRestore(); + } + + DatabaseService.getInstance().getDb() + .prepare('UPDATE stack_git_sources SET auto_apply_on_webhook = 0, last_debounce_at = ? WHERE stack_name = ?') + .run(Date.now() - 999_999, 'webhook-apply-reservation-fails-closed'); + try { + const redelivery = await svc.handleWebhookPull( + 'webhook-apply-reservation-fails-closed', + true, + 'delivery-apply-reservation-failure', + ); + expect(redelivery.status).toBe('success'); + expect(saveSpy).toHaveBeenCalledTimes(1); + } finally { + saveSpy.mockRestore(); + } + }); + + it('reserves and durably settles an attempt for a successful webhook fetch', async () => { + mockSuccessfulClone({ sha: '8'.repeat(40) }); + const svc = GitSourceService.getInstance(); + await configureGitSource('webhook-reserves'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('webhook-reserves')!.id; + mockGitClone.mockClear(); + mockSuccessfulClone({ sha: '9'.repeat(40) }); + + const result = await svc.handleWebhookPull('webhook-reserves', true); + + expect(result.status).toBe('success'); + expect(historyOperationIds(applicationId, 'source_reconcile_settled').length).toBeGreaterThanOrEqual(1); + expect(GitOpsStore.getInstance().listUnsettledReconcileAttempts().some((r) => r.application_id === applicationId)).toBe(false); + }); + + it('deduplicates a webhook redelivery by its stable delivery id after the debounce window expires', async () => { + const sha = 'a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1'; + mockSuccessfulClone({ sha }); + const svc = GitSourceService.getInstance(); + await configureGitSource('webhook-delivery-recorded'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('webhook-delivery-recorded')!.id; + + const first = await svc.handleWebhookPull('webhook-delivery-recorded', true, 'delivery-xyz'); + expect(first.status).toBe('success'); + + DatabaseService.getInstance().getDb() + .prepare('UPDATE stack_git_sources SET last_debounce_at = ? WHERE stack_name = ?') + .run(Date.now() - 999_999, 'webhook-delivery-recorded'); + mockGitClone.mockClear(); + + const redelivery = await svc.handleWebhookPull('webhook-delivery-recorded', true, 'delivery-xyz'); + + expect(mockGitClone).not.toHaveBeenCalled(); + expect(redelivery.status).toBe('success'); + expect(historyOperationIds(applicationId, 'source_reconcile_started')).toHaveLength(1); + expect(historyOperationIds(applicationId, 'source_reconcile_settled')).toHaveLength(1); + }); + + it('joins a concurrent redelivery to the whole webhook fetch-and-apply execution', async () => { + const sha = 'a4'.repeat(20); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + const svc = GitSourceService.getInstance(); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + let releaseSave!: () => void; + const saveGate = new Promise((resolve) => { releaseSave = resolve; }); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockImplementation(async () => { await saveGate; }); + + try { + await svc.upsert({ + stackName: 'webhook-whole-delivery-coalesce', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: true, + autoDeployOnApply: false, + }); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('webhook-whole-delivery-coalesce')!.id; + mockGitClone.mockClear(); + + const first = svc.handleWebhookPull('webhook-whole-delivery-coalesce', true, 'delivery-whole-execution'); + await vi.waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); + const redelivery = svc.handleWebhookPull('webhook-whole-delivery-coalesce', true, 'delivery-whole-execution'); + releaseSave(); + const [firstResult, redeliveryResult] = await Promise.all([first, redelivery]); + + expect(redeliveryResult).toEqual(firstResult); + expect(mockGitClone).toHaveBeenCalledTimes(1); + expect(saveSpy).toHaveBeenCalledTimes(1); + expect(historyOperationIds(applicationId, 'source_reconcile_started')).toHaveLength(2); + expect(historyOperationIds(applicationId, 'source_reconcile_settled')).toHaveLength(2); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('does not add an apply step to a fetch-only delivery when settings change before redelivery', async () => { + const sha = 'a2'.repeat(20); + mockSuccessfulClone({ sha }); + const svc = GitSourceService.getInstance(); + await configureGitSource('webhook-redelivery-settings-change'); + + const first = await svc.handleWebhookPull('webhook-redelivery-settings-change', true, 'delivery-settings-change'); + expect(first.status).toBe('success'); + + const db = DatabaseService.getInstance().getDb(); + db.prepare('UPDATE stack_git_sources SET auto_apply_on_webhook = 1, last_debounce_at = ? WHERE stack_name = ?') + .run(Date.now() - 999_999, 'webhook-redelivery-settings-change'); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + mockGitClone.mockClear(); + + try { + const redelivery = await svc.handleWebhookPull('webhook-redelivery-settings-change', true, 'delivery-settings-change'); + expect(redelivery.status).toBe('success'); + expect(mockGitClone).not.toHaveBeenCalled(); + expect(saveSpy).not.toHaveBeenCalled(); + } finally { + saveSpy.mockRestore(); + } + }); + + it('returns the stored apply failure when auto-apply is disabled before redelivery', async () => { + const sha = 'a3'.repeat(20); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + const svc = GitSourceService.getInstance(); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const { ComposeService } = await import('../services/ComposeService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockRejectedValue(new Error('simulated webhook deploy failure')); + + try { + await svc.upsert({ + stackName: 'webhook-redelivery-apply-failure', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: true, + autoDeployOnApply: true, + }); + + const first = await svc.handleWebhookPull('webhook-redelivery-apply-failure', true, 'delivery-apply-failure'); + expect(first.status).toBe('error'); + expect(first.message).toContain('simulated webhook deploy failure'); + + DatabaseService.getInstance().getDb() + .prepare('UPDATE stack_git_sources SET auto_apply_on_webhook = 0, auto_deploy_on_apply = 0, last_debounce_at = ? WHERE stack_name = ?') + .run(Date.now() - 999_999, 'webhook-redelivery-apply-failure'); + mockGitClone.mockClear(); + saveSpy.mockClear(); + deploySpy.mockClear(); + + const redelivery = await svc.handleWebhookPull('webhook-redelivery-apply-failure', true, 'delivery-apply-failure'); + + expect(redelivery.status).toBe('error'); + expect(redelivery.message).toContain('simulated webhook deploy failure'); + expect(mockGitClone).not.toHaveBeenCalled(); + expect(saveSpy).not.toHaveBeenCalled(); + expect(deploySpy).not.toHaveBeenCalled(); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + deploySpy.mockRestore(); + } + }); + + it('logs the recognized delivery id as a traceability breadcrumb when a webhook pull fails', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: '6'.repeat(40) }); + await configureGitSource('webhook-delivery-breadcrumb'); + mockGitClone.mockClear(); + mockGitClone.mockRejectedValueOnce(new Error('simulated network failure')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const result = await svc.handleWebhookPull('webhook-delivery-breadcrumb', true, 'delivery-log-1'); + expect(result.status).toBe('error'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('(delivery delivery-log-1)')); + } finally { + errorSpy.mockRestore(); + } + }); + it('runs a single clone for a concurrent webhook fan-out', async () => { - // The original failure: N webhooks for one push each ran a full clone - // because the debounce gate was read before the per-stack lock. The - // gate now lives inside the lock, so the first request stamps the - // window and the rest skip. + // Concurrent deliveries all reserve and join one shared fetch. Every + // caller receives the leader's normalized result, while only the + // leader performs the clone. const sha = 'eeee555eeee555eeee555eeee555eeee555eeee5'; mockSuccessfulClone({ sha }); const svc = GitSourceService.getInstance(); @@ -1348,15 +1941,85 @@ describe('GitSourceService.handleWebhookPull debounce', () => { mockGitClone.mockClear(); const results = await Promise.all( - Array.from({ length: 5 }, () => svc.handleWebhookPull('fanout-stack')), + Array.from({ length: 5 }, () => svc.handleWebhookPull('fanout-stack', true)), ); expect(mockGitClone.mock.calls.length).toBe(1); - expect(results.filter(r => r.status === 'success')).toHaveLength(1); - expect(results.filter(r => r.status === 'skipped')).toHaveLength(4); + expect(results.filter(r => r.status === 'success')).toHaveLength(5); + expect(results.filter(r => r.status === 'skipped')).toHaveLength(0); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('fanout-stack')!.id; + const settled = settledAttemptsForApplication(applicationId); + expect(settled).toHaveLength(5); + expect(new Set(settled.map((row) => row.after_json)).size).toBe(1); validateSpy.mockRestore(); }); + it('coalesces a webhook fetch with a concurrent manual pull', async () => { + const sha = 'ef'.repeat(20); + mockSuccessfulClone({ sha }); + const svc = GitSourceService.getInstance(); + await configureGitSource('webhook-manual-fetch-coalesce'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('webhook-manual-fetch-coalesce')!.id; + + let releaseClone!: () => void; + const cloneGate = new Promise((resolve) => { releaseClone = resolve; }); + mockGitClone.mockClear(); + mockGitClone.mockImplementation(async (args: { dir: string }) => { + await cloneGate; + const { promises: fsp } = await import('fs'); + const path = await import('path'); + await fsp.writeFile(path.join(args.dir, 'compose.yaml'), 'services:\n x:\n image: alpine\n', 'utf-8'); + }); + mockGitLog.mockResolvedValue([{ oid: sha }]); + + const webhook = svc.handleWebhookPull('webhook-manual-fetch-coalesce', true, 'delivery-cross-producer'); + await vi.waitFor(() => expect(mockGitClone).toHaveBeenCalledTimes(1)); + const manual = svc.pull('webhook-manual-fetch-coalesce'); + releaseClone(); + const [webhookResult, manualResult] = await Promise.all([webhook, manual]); + + expect(webhookResult.status).toBe('success'); + expect(manualResult.commitSha).toBe(sha); + expect(mockGitClone).toHaveBeenCalledTimes(1); + const settled = DatabaseService.getInstance().getDb() + .prepare("SELECT after_json FROM gitops_history WHERE application_id = ? AND stage = 'source_reconcile_settled'") + .all(applicationId) as { after_json: string }[]; + expect(settled).toHaveLength(2); + expect(new Set(settled.map((row) => row.after_json)).size).toBe(1); + }); + + it('coalesces a manual pull with a concurrent webhook fetch', async () => { + const sha = 'f0'.repeat(20); + mockSuccessfulClone({ sha }); + const svc = GitSourceService.getInstance(); + await configureGitSource('manual-webhook-fetch-coalesce'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('manual-webhook-fetch-coalesce')!.id; + + let releaseClone!: () => void; + const cloneGate = new Promise((resolve) => { releaseClone = resolve; }); + mockGitClone.mockClear(); + mockGitClone.mockImplementation(async (args: { dir: string }) => { + await cloneGate; + const { promises: fsp } = await import('fs'); + const path = await import('path'); + await fsp.writeFile(path.join(args.dir, 'compose.yaml'), 'services:\n x:\n image: alpine\n', 'utf-8'); + }); + mockGitLog.mockResolvedValue([{ oid: sha }]); + + const manual = svc.pull('manual-webhook-fetch-coalesce'); + await vi.waitFor(() => expect(mockGitClone).toHaveBeenCalledTimes(1)); + const webhook = svc.handleWebhookPull('manual-webhook-fetch-coalesce', true, 'delivery-manual-leader'); + releaseClone(); + const [manualResult, webhookResult] = await Promise.all([manual, webhook]); + + expect(manualResult.commitSha).toBe(sha); + expect(webhookResult.status).toBe('success'); + expect(mockGitClone).toHaveBeenCalledTimes(1); + const settled = settledAttemptsForApplication(applicationId); + expect(settled).toHaveLength(2); + expect(new Set(settled.map((row) => row.after_json)).size).toBe(1); + }); + it('returns error when the pulled compose fails validation', async () => { mockSuccessfulClone(); const svc = GitSourceService.getInstance(); @@ -1378,7 +2041,7 @@ describe('GitSourceService.handleWebhookPull debounce', () => { .spyOn(svc as unknown as { runDockerCompose: (a: string[], c: string, t: number) => Promise<{ code: number; stdout: string; stderr: string }> }, 'runDockerCompose') .mockResolvedValue({ code: 1, stdout: '', stderr: 'bad compose' }); - const result = await svc.handleWebhookPull('webhook-validate-fail'); + const result = await svc.handleWebhookPull('webhook-validate-fail', true); expect(result.status).toBe('error'); expect(result.message).toMatch(/validation failed/i); runSpy.mockRestore(); @@ -1410,7 +2073,7 @@ describe('GitSourceService.handleWebhookPull debounce', () => { existing: { action: 'update', actor: 'user:admin', startedAt: Date.now() }, } as never); - const result = await svc.handleWebhookPull('webhook-shared-lock'); + const result = await svc.handleWebhookPull('webhook-shared-lock', true); expect(result.status).toBe('error'); expect(result.message).toMatch(/already in progress/i); expect(runExclusive).toHaveBeenCalledWith( @@ -1611,6 +2274,224 @@ describe('GitSourceService.pull', () => { await expect(svc.pull('does-not-exist')).rejects.toMatchObject({ code: 'GIT_ERROR' }); }); + it('reserves and durably settles an attempt for a successful pull', async () => { + await createFromGit('pull-reserves', '2'.repeat(40)); + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx:2\n', sha: '3'.repeat(40) }); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('pull-reserves')!.id; + + await svc.pull('pull-reserves'); + + expect(historyOperationIds(applicationId, 'source_reconcile_settled').length).toBeGreaterThanOrEqual(1); + }); + + it('stamps the pending fetch record with the same operation id the reserved attempt used, not an independent one', async () => { + await createFromGit('pull-pending-lineage', '2'.repeat(40)); + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx:2\n', sha: '3'.repeat(40) }); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('pull-pending-lineage')!.id; + + await svc.pull('pull-pending-lineage'); + + const [reservedOperationId] = historyOperationIds(applicationId, 'source_reconcile_started'); + expect(reservedOperationId).toBeTruthy(); + const row = DatabaseService.getInstance().getGitSource('pull-pending-lineage'); + const decoded = (svc as unknown as { + decodePendingCompose: (raw: string) => { operationId: string | null }; + }).decodePendingCompose(row!.pending_compose_content!); + expect(decoded.operationId).toBe(reservedOperationId); + }); + + it('coalesces two concurrent pulls for the same stack into one clone', async () => { + const svc = GitSourceService.getInstance(); + await createFromGit('pull-coalesce', '4'.repeat(40)); + + let releaseClone!: () => void; + const gate = new Promise((resolve) => { releaseClone = resolve; }); + mockGitClone.mockClear(); + mockGitClone.mockImplementation(async (args: { dir: string }) => { + await gate; + const { promises: fsp } = await import('fs'); + const path = await import('path'); + const composeAbs = path.join(args.dir, 'compose.yaml'); + await fsp.mkdir(path.dirname(composeAbs), { recursive: true }); + await fsp.writeFile(composeAbs, 'services:\n web:\n image: nginx:3\n', 'utf-8'); + }); + mockGitLog.mockResolvedValue([{ oid: '5'.repeat(40) }]); + + const first = svc.pull('pull-coalesce'); + const second = svc.pull('pull-coalesce'); + releaseClone(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(mockGitClone).toHaveBeenCalledTimes(1); + expect(secondResult).toEqual(firstResult); + }); + + it('fails closed and does not clone when reservation itself fails', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: '1'.repeat(40) }); + await configureGitSource('pull-reservation-fails-closed'); + mockGitClone.mockClear(); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('pull-reservation-fails-closed')!.id; + const reserveSpy = vi.spyOn(GitOpsTransitions.prototype, 'allocateReconcileAttempt') + .mockImplementationOnce(() => { throw new Error('simulated reservation failure'); }); + + try { + await expect(svc.pull('pull-reservation-fails-closed')).rejects.toMatchObject({ code: 'GIT_ERROR' }); + expect(mockGitClone).not.toHaveBeenCalled(); + expect(historyOperationIds(applicationId, 'source_reconcile_started')).toHaveLength(0); + } finally { + reserveSpy.mockRestore(); + } + }); + + it('fails closed and does not clone when the application was detached but the source config survives', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: '1'.repeat(40) }); + await configureGitSource('pull-tombstoned-app'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('pull-tombstoned-app')!.id; + GitOpsTransitions.getInstance().applicationTombstoned(applicationId, 'detached', { + operationId: 'op-detach-1', actor: 'tester', trigger: 'test', at: Date.now(), + }); + expect(DatabaseService.getInstance().getGitSource('pull-tombstoned-app')).toBeDefined(); + mockGitClone.mockClear(); + const activitySpy = vi.spyOn(DatabaseService.getInstance(), 'addNotificationHistory'); + + try { + await expect(svc.pull('pull-tombstoned-app')).rejects.toMatchObject({ + code: 'GIT_ERROR', + message: expect.stringContaining('GitOps tracking was removed'), + }); + expect(mockGitClone).not.toHaveBeenCalled(); + expect(historyOperationIds(applicationId, 'source_reconcile_started')).toHaveLength(0); + expect(activitySpy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + category: 'git_pull_failed', + stack_name: 'pull-tombstoned-app', + })); + } finally { + activitySpy.mockRestore(); + } + }); + + it('fails closed when the application was deleted, then restores tracked pulls after reconfiguration', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: '4'.repeat(40) }); + await configureGitSource('pull-deleted-app-refused'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('pull-deleted-app-refused')!.id; + GitOpsTransitions.getInstance().applicationTombstoned(applicationId, 'deleted', { + operationId: 'op-delete-1', actor: 'tester', trigger: 'test', at: Date.now(), + }); + mockGitClone.mockClear(); + + await expect(svc.pull('pull-deleted-app-refused')).rejects.toMatchObject({ + code: 'GIT_ERROR', + message: expect.stringContaining('GitOps tracking is unavailable'), + }); + expect(mockGitClone).not.toHaveBeenCalled(); + + await configureGitSource('pull-deleted-app-refused'); + expect(GitOpsStore.getInstance().getLiveDirectApplication('pull-deleted-app-refused')?.id).not.toBe(applicationId); + mockGitClone.mockClear(); + await svc.pull('pull-deleted-app-refused'); + expect(mockGitClone).toHaveBeenCalledTimes(1); + }); + + it('falls through to the ordinary no-source error for a completed detach, not the detach-in-progress message', async () => { + // detach() commits applicationTombstoned('detached') and + // deleteGitSource in one transaction, so a routine, fully + // successful detach leaves exactly this state: a detached + // tombstone with NO surviving source row. The detach-in-progress + // refusal must not fire here, or every previously-detached stack + // name would get a false, unactionable message instead of the + // real, correct "no source configured" error. + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: '5'.repeat(40) }); + await configureGitSource('pull-completed-detach'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('pull-completed-detach')!.id; + GitOpsTransitions.getInstance().applicationTombstoned(applicationId, 'detached', { + operationId: 'op-detach-4', actor: 'tester', trigger: 'test', at: Date.now(), + }); + DatabaseService.getInstance().deleteGitSource('pull-completed-detach'); + mockGitClone.mockClear(); + + await expect(svc.pull('pull-completed-detach')).rejects.toMatchObject({ + code: 'GIT_ERROR', + message: 'No Git source configured for this stack.', + }); + expect(mockGitClone).not.toHaveBeenCalled(); + }); + + it('does not mask the real pull failure when deriving the settlement result afterward also throws', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: '1'.repeat(40) }); + await configureGitSource('pull-mask-error'); + // A real gitops application exists (so this call reserves an + // attempt), but the source config row is now gone, so pullLocked + // itself throws a specific, truthful error. + DatabaseService.getInstance().deleteGitSource('pull-mask-error'); + const deriveSpy = vi.spyOn(svc as unknown as { deriveReconcileResult: (s: string) => unknown }, 'deriveReconcileResult') + .mockImplementationOnce(() => { throw new Error('derivation boom'); }); + + try { + await expect(svc.pull('pull-mask-error')).rejects.toMatchObject({ + code: 'GIT_ERROR', + message: expect.stringContaining('No Git source configured'), + }); + } finally { + deriveSpy.mockRestore(); + } + }); + + it('fails closed when a reservation collision is forced with no in-process leader', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: '1'.repeat(40) }); + await configureGitSource('pull-forced-collision'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('pull-forced-collision')!.id; + const nextSeq = GitOpsStore.getInstance().getApplication(applicationId)!.attempt_seq + 1; + const predictedOperationId = `${applicationId}:attempt:${nextSeq}`; + // Force the exact operation id pull() is about to allocate to + // already be reserved, simulating a collision with no in-process + // leader for it. The call must not run untracked work under an + // operation id already owned by another submission. + GitOpsTransitions.getInstance().reserveReconcileAttempt(applicationId, { + operationId: predictedOperationId, actor: 'someone-else', trigger: 'poll', at: Date.now(), + }); + mockSuccessfulClone({ sha: '2'.repeat(40) }); + mockGitClone.mockClear(); + await expect(svc.pull('pull-forced-collision')).rejects.toMatchObject({ + code: 'GIT_ERROR', + message: expect.stringContaining('already recorded'), + }); + expect(mockGitClone).not.toHaveBeenCalled(); + }); + + it('settles a coalesced follower\'s own attempt even when the leader\'s fetch rejects', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: '1'.repeat(40) }); + await configureGitSource('pull-follower-settles-on-reject'); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('pull-follower-settles-on-reject')!.id; + + let releaseClone!: () => void; + const gate = new Promise((resolve) => { releaseClone = resolve; }); + mockGitClone.mockClear(); + mockGitClone.mockImplementation(async () => { + await gate; + throw new Error('simulated clone failure'); + }); + + const first = svc.pull('pull-follower-settles-on-reject'); + const second = svc.pull('pull-follower-settles-on-reject'); + releaseClone(); + await expect(first).rejects.toThrow('simulated clone failure'); + await expect(second).rejects.toThrow('simulated clone failure'); + + // Both the leader's and the follower's own reservations must be + // durably settled; neither may be left open waiting for a crash + // that never happened. + expect(GitOpsStore.getInstance().listUnsettledReconcileAttempts().some((r) => r.application_id === applicationId)).toBe(false); + }); + function generationCount(stackName: string): number { const app = GitOpsStore.getInstance().getLiveDirectApplication(stackName)!; return (DatabaseService.getInstance().getDb() @@ -2058,6 +2939,1701 @@ describe('GitSourceService.apply', () => { return svc; } + function liveApp(stackName: string) { + return GitOpsStore.getInstance().getLiveDirectApplication(stackName); + } + + it('fails closed and does not write or deploy when reservation itself fails, even for a deploying apply', async () => { + const sha = 'df'.repeat(20); + const svc = await seedPending('apply-reservation-fails-closed', 'services:\n x:\n image: alpine\n', sha); + const applicationId = liveApp('apply-reservation-fails-closed')!.id; + const { FileSystemService } = await import('../services/FileSystemService'); + const { ComposeService } = await import('../services/ComposeService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack'); + const startedBefore = historyOperationIds(applicationId, 'source_reconcile_started').length; + const reserveSpy = vi.spyOn(GitOpsTransitions.prototype, 'allocateReconcileAttempt') + .mockImplementationOnce(() => { throw new Error('simulated reservation failure'); }); + + try { + await expect(svc.apply('apply-reservation-fails-closed', sha, { ...SKIP_PLAN_FINGERPRINT, deploy: true })) + .rejects.toMatchObject({ code: 'GIT_ERROR' }); + expect(saveSpy).not.toHaveBeenCalled(); + expect(deploySpy).not.toHaveBeenCalled(); + // No new attempt at all, settled or unsettled: reservation + // itself never landed, so there is nothing new to track. + expect(historyOperationIds(applicationId, 'source_reconcile_started')).toHaveLength(startedBefore); + } finally { + saveSpy.mockRestore(); + deploySpy.mockRestore(); + reserveSpy.mockRestore(); + } + }); + + it('fails closed and does not write when the application was detached but the pending commit survives', async () => { + const sha = 'db'.repeat(20); + const svc = await seedPending('apply-tombstoned-app', 'services:\n x:\n image: alpine\n', sha); + const applicationId = liveApp('apply-tombstoned-app')!.id; + GitOpsTransitions.getInstance().applicationTombstoned(applicationId, 'detached', { + operationId: 'op-detach-2', actor: 'tester', trigger: 'test', at: Date.now(), + }); + expect(DatabaseService.getInstance().getGitSource('apply-tombstoned-app')?.pending_commit_sha).toBe(sha); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + const activitySpy = vi.spyOn(DatabaseService.getInstance(), 'addNotificationHistory'); + + try { + await expect(svc.apply('apply-tombstoned-app', sha, SKIP_PLAN_FINGERPRINT)).rejects.toMatchObject({ + code: 'GIT_ERROR', + message: expect.stringContaining('GitOps tracking was removed'), + }); + expect(saveSpy).not.toHaveBeenCalled(); + expect(activitySpy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + category: 'git_apply_failed', + stack_name: 'apply-tombstoned-app', + })); + } finally { + saveSpy.mockRestore(); + activitySpy.mockRestore(); + } + }); + + it('fails closed and does not write or deploy when the application was deleted but the pending commit survives', async () => { + const sha = 'dc'.repeat(20); + const svc = await seedPending('apply-deleted-app', 'services:\n x:\n image: alpine\n', sha); + const applicationId = liveApp('apply-deleted-app')!.id; + GitOpsTransitions.getInstance().applicationTombstoned(applicationId, 'deleted', { + operationId: 'op-delete-apply', actor: 'tester', trigger: 'test', at: Date.now(), + }); + const { FileSystemService } = await import('../services/FileSystemService'); + const { ComposeService } = await import('../services/ComposeService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack'); + + try { + await expect(svc.apply('apply-deleted-app', sha, { ...SKIP_PLAN_FINGERPRINT, deploy: true })) + .rejects.toMatchObject({ + code: 'GIT_ERROR', + message: expect.stringContaining('GitOps tracking is unavailable'), + }); + expect(saveSpy).not.toHaveBeenCalled(); + expect(deploySpy).not.toHaveBeenCalled(); + } finally { + saveSpy.mockRestore(); + deploySpy.mockRestore(); + } + }); + + it('reserves and durably settles an attempt for a successful apply', async () => { + const sha = '6'.repeat(40); + const svc = await seedPending('apply-reserves', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + + try { + const applicationId = liveApp('apply-reserves')!.id; + await svc.apply('apply-reserves', sha, SKIP_PLAN_FINGERPRINT); + + const settled = historyOperationIds(applicationId, 'source_reconcile_settled'); + const applied = historyOperationIds(applicationId, 'applied'); + expect(settled.length).toBeGreaterThanOrEqual(1); + expect(applied).toHaveLength(1); + expect(settled).toContain(applied[0]); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('coalesces two concurrent applies for the same commit into one execution', async () => { + const sha = '7'.repeat(40); + const svc = await seedPending('apply-coalesce', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + let releaseSave!: () => void; + const gate = new Promise((resolve) => { releaseSave = resolve; }); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockImplementation(async () => { await gate; }); + + try { + const first = svc.apply('apply-coalesce', sha, SKIP_PLAN_FINGERPRINT); + const second = svc.apply('apply-coalesce', sha, SKIP_PLAN_FINGERPRINT); + releaseSave(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(saveSpy).toHaveBeenCalledTimes(1); + expect(secondResult).toEqual(firstResult); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('does not coalesce two concurrent applies that resolve to different deploy behavior', async () => { + const sha = 'ba'.repeat(20); + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + const seedValidateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + try { + await svc.upsert({ + stackName: 'apply-deploy-mismatch', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: true, + autoDeployOnApply: true, + }); + await svc.pull('apply-deploy-mismatch'); + } finally { + seedValidateSpy.mockRestore(); + } + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + let releaseSave!: () => void; + const gate = new Promise((resolve) => { releaseSave = resolve; }); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockImplementation(async () => { await gate; }); + const { ComposeService } = await import('../services/ComposeService'); + const { HealthGateService } = await import('../services/HealthGateService'); + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue({ recoveryId: null, deployedGenerationId: null }); + const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'beginStack').mockReturnValue('gate-git'); + + try { + // The stack has auto_deploy_on_apply: true. The first call + // leaves deploy unresolved (so it resolves to true); the + // second explicitly asks not to deploy. These must never + // share a coalesce key: if they wrongly joined, the second + // call would resolve successfully with the first's deployed + // result instead of running (or failing) on its own terms. + // Since they do not join, and applying clears the pending + // commit, the second genuinely has nothing left to apply + // once the first (which the per-stack lock serializes first) + // completes -- a real, honest failure, not a borrowed result. + const first = svc.apply('apply-deploy-mismatch', sha, SKIP_PLAN_FINGERPRINT); + const second = svc.apply('apply-deploy-mismatch', sha, { ...SKIP_PLAN_FINGERPRINT, deploy: false }); + releaseSave(); + const firstResult = await first; + expect(firstResult.deployed).toBe(true); + await expect(second).rejects.toMatchObject({ code: 'GIT_ERROR' }); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + deploySpy.mockRestore(); + beginSpy.mockRestore(); + } + }); + + describe('reconcile', () => { + it('reports candidate_already_fetched after a fetch-intent reconcile stages a new candidate', async () => { + const sha = 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1'; + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + const svc = GitSourceService.getInstance(); + await svc.upsert({ + stackName: 'reconcile-fetch', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + + try { + const applicationId = liveApp('reconcile-fetch')!.id; + const result = await svc.reconcile({ + intent: 'fetch', + applicationId, + stackName: 'reconcile-fetch', + trigger: 'manual', + actor: 'tester', + }); + expect(result.outcome).toBe('candidate_already_fetched'); + } finally { + validateSpy.mockRestore(); + } + }); + + it('reports no_source_change after an apply-intent reconcile accepts the candidate', async () => { + const sha = 'e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2'; + const svc = await seedPending('reconcile-apply', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + + try { + const applicationId = liveApp('reconcile-apply')!.id; + const result = await svc.reconcile({ + intent: 'apply', + applicationId, + stackName: 'reconcile-apply', + trigger: 'manual', + actor: 'tester', + commitSha: sha, + planFingerprint: '', + deploy: false, + }); + expect(result.outcome).toBe('no_source_change'); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('does not report success when the source applied but the deploy failed', async () => { + const sha = 'e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7'; + const svc = await seedPending('reconcile-apply-deploy-fail', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const { ComposeService } = await import('../services/ComposeService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockRejectedValue( + new Error('compose up failed: docker unavailable'), + ); + + try { + const applicationId = liveApp('reconcile-apply-deploy-fail')!.id; + const result = await svc.reconcile({ + intent: 'apply', + applicationId, + stackName: 'reconcile-apply-deploy-fail', + trigger: 'manual', + actor: 'tester', + commitSha: sha, + planFingerprint: '', + deploy: true, + }); + // The promotion itself succeeded (files landed, generation + // accepted), so the source facet alone reads as converged. + // reconcile must not let that mask the deploy failure, and must + // not claim the previous generation is unchanged either: it isn't. + expect(result.outcome).toBe('recovery_required'); + expect(result.nextAction).toBe('view_target_results'); + expect(result.reason).toMatch(/deploy/i); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + deploySpy.mockRestore(); + } + }); + + it('reports a truthful failure, not the stale staged-candidate outcome, when fetch throws before touching the application row', async () => { + const sha = 'e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3'; + const svc = await seedPending('reconcile-fetch-fail', 'services:\n x:\n image: alpine\n', sha); + const applicationId = liveApp('reconcile-fetch-fail')!.id; + // Deleting the config row makes pullLocked throw its `!src` guard, + // which fires before fetchStarted opens any transition: the + // application row is untouched by this failure. + DatabaseService.getInstance().deleteGitSource('reconcile-fetch-fail'); + + const result = await svc.reconcile({ + intent: 'fetch', + applicationId, + stackName: 'reconcile-fetch-fail', + trigger: 'manual', + actor: 'tester', + }); + + expect(result.outcome).not.toBe('candidate_already_fetched'); + expect(result.nextAction).not.toBe('none'); + }); + + it('reports a truthful failure, not the stale staged-candidate outcome, when apply throws on a stale commitSha', async () => { + const sha = 'e4e4e4e4e4e4e4e4e4e4e4e4e4e4e4e4e4e4e4e4'; + const svc = await seedPending('reconcile-apply-stale-sha', 'services:\n x:\n image: alpine\n', sha); + const applicationId = liveApp('reconcile-apply-stale-sha')!.id; + + const result = await svc.reconcile({ + intent: 'apply', + applicationId, + stackName: 'reconcile-apply-stale-sha', + trigger: 'manual', + actor: 'tester', + commitSha: 'ffffffffffffffffffffffffffffffffffffffff', + planFingerprint: '', + deploy: false, + }); + + expect(result.outcome).not.toBe('candidate_already_fetched'); + expect(result.nextAction).not.toBe('none'); + }); + + it('fails closed instead of silently reconciling the wrong application when the requested applicationId is stale', async () => { + const sha = 'e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5'; + const svc = await seedPending('reconcile-stale-app-id', 'services:\n x:\n image: alpine\n', sha); + + const result = await svc.reconcile({ + intent: 'fetch', + applicationId: 'no-longer-the-live-application', + stackName: 'reconcile-stale-app-id', + trigger: 'manual', + actor: 'tester', + }); + + expect(result.outcome).toBe('unknown'); + expect(result.nextAction).toBe('none'); + // Fails closed before doing anything: the candidate this stack had + // staged before the call is still exactly as it was. + const stillStaged = liveApp('reconcile-stale-app-id'); + expect(stillStaged?.candidate_generation_id).toBeTruthy(); + }); + + it('fails closed on a stale applicationId even when the live application is stuck in creating, not active', async () => { + const sha = 'e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6'; + const svc = await seedPending('reconcile-creating-app', 'services:\n x:\n image: alpine\n', sha); + const applicationId = liveApp('reconcile-creating-app')!.id; + // gitopsApplicationFor() (used elsewhere to gate transitions) only + // recognizes 'active' rows, but getLiveDirectApplication() (used + // by deriveReconcileResult) recognizes 'active' and 'creating' + // alike. reconcile's identity guard must use the same broad + // definition, or a 'creating' row slips past it entirely. + DatabaseService.getInstance().getDb() + .prepare("UPDATE gitops_applications SET lifecycle_status = 'creating' WHERE id = ?") + .run(applicationId); + + const result = await svc.reconcile({ + intent: 'apply', + applicationId: 'deliberately-mismatched-id', + stackName: 'reconcile-creating-app', + trigger: 'manual', + actor: 'tester', + commitSha: 'ffffffffffffffffffffffffffffffffffffffff', + planFingerprint: '', + deploy: false, + }); + + expect(result.outcome).toBe('unknown'); + expect(result.nextAction).toBe('none'); + // Fails closed before doing anything: the candidate this stack + // had staged before the call is still exactly as it was. + const stillStaged = liveApp('reconcile-creating-app'); + expect(stillStaged?.candidate_generation_id).toBeTruthy(); + }); + + it('fails closed instead of silently applying under the current live application when the requested applicationId still exists but was superseded', async () => { + const sha = 'e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7'; + const svc = await seedPending('reconcile-apply-superseded-id', 'services:\n x:\n image: alpine\n', sha); + const staleApplicationId = liveApp('reconcile-apply-superseded-id')!.id; + // A real row that used to be live for this stack, not a + // fabricated id: this is the exact gap the existing stale-id + // tests (using ids that never existed as any row) do not cover, + // since GitOpsStore.getApplication finds this row just fine. + GitOpsTransitions.getInstance().applicationTombstoned(staleApplicationId, 'detached', { + operationId: 'op-supersede-1', actor: 'tester', trigger: 'test', at: Date.now(), + }); + const config: DirectSourceConfig = { + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + }; + GitOpsStore.getInstance().insertApplication(buildDirectApplicationRow({ + id: newGitOpsId(), + stackName: 'reconcile-apply-superseded-id', + config, + identity: directSourceIdentity(config), + lifecycleStatus: 'active', + at: Date.now(), + })); + const newLiveId = liveApp('reconcile-apply-superseded-id')!.id; + expect(newLiveId).not.toBe(staleApplicationId); + + const applySpy = vi.spyOn( + svc as unknown as { applyWithSharedLock: (...args: unknown[]) => Promise }, + 'applyWithSharedLock', + ); + + try { + const result = await svc.reconcile({ + intent: 'apply', + applicationId: staleApplicationId, + stackName: 'reconcile-apply-superseded-id', + trigger: 'manual', + actor: 'tester', + commitSha: 'ffffffffffffffffffffffffffffffffffffffff', + planFingerprint: '', + deploy: false, + }); + + expect(result.outcome).toBe('unknown'); + expect(result.nextAction).toBe('none'); + expect(applySpy).not.toHaveBeenCalled(); + } finally { + applySpy.mockRestore(); + } + }); + + it('revalidates the application identity after acquiring the fetch lock', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: 'fa'.repeat(20) }); + await configureGitSource('reconcile-fetch-replaced-while-queued'); + const staleApplicationId = liveApp('reconcile-fetch-replaced-while-queued')!.id; + mockGitClone.mockClear(); + + let releaseLock!: () => void; + const lockGate = new Promise((resolve) => { releaseLock = resolve; }); + const lockHolder = (svc as unknown as { + withStackLock: (stackName: string, fn: () => Promise) => Promise; + }).withStackLock('reconcile-fetch-replaced-while-queued', () => lockGate); + + const reconcile = svc.reconcile({ + intent: 'fetch', + applicationId: staleApplicationId, + stackName: 'reconcile-fetch-replaced-while-queued', + trigger: 'poll', + actor: 'system:source-controller', + }); + await vi.waitFor(() => { + expect(historyOperationIds(staleApplicationId, 'source_reconcile_started')).toHaveLength(1); + }); + + GitOpsTransitions.getInstance().applicationTombstoned(staleApplicationId, 'detached', { + operationId: 'op-replace-queued-fetch', actor: 'tester', trigger: 'test', at: Date.now(), + }); + const config: DirectSourceConfig = { + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + }; + GitOpsStore.getInstance().insertApplication(buildDirectApplicationRow({ + id: newGitOpsId(), + stackName: 'reconcile-fetch-replaced-while-queued', + config, + identity: directSourceIdentity(config), + lifecycleStatus: 'active', + at: Date.now(), + })); + + releaseLock(); + await lockHolder; + const result = await reconcile; + + expect(result.outcome).toBe('unknown'); + expect(result.nextAction).toBe('none'); + expect(mockGitClone).not.toHaveBeenCalled(); + expect(settledAttempts(staleApplicationId)).toHaveLength(1); + expect(historyOperationIds(liveApp('reconcile-fetch-replaced-while-queued')!.id, 'fetch_started')).toHaveLength(0); + }); + + it('captures the fetch result before a queued source mutation can change row state', async () => { + const stackName = 'reconcile-settlement-before-queued-suspend'; + const sha = 'f1'.repeat(20); + mockSuccessfulClone({ sha }); + const svc = GitSourceService.getInstance(); + await configureGitSource(stackName); + const applicationId = liveApp(stackName)!.id; + const releaseClone = gatedClone(); + mockGitLog.mockResolvedValue([{ oid: sha }]); + + const fetch = svc.reconcile({ + intent: 'fetch', + applicationId, + stackName, + trigger: 'poll', + actor: 'system:source-controller', + }); + await vi.waitFor(() => expect(mockGitClone).toHaveBeenCalledTimes(1)); + const suspend = svc.suspend(stackName, { actor: 'tester', reason: 'queue behind fetch' }); + releaseClone(); + + const [fetchResult, suspendResult] = await Promise.all([fetch, suspend]); + + expect(fetchResult.outcome).toBe('candidate_already_fetched'); + expect(suspendResult.outcome).toBe('suspended'); + const settled = settledAttempts(applicationId); + expect(settled).toHaveLength(1); + expect(JSON.parse(settled[0].after_json).outcome).toBe('candidate_already_fetched'); + }); + + it('reports unknown for a stack with no GitOps application', async () => { + const svc = GitSourceService.getInstance(); + const result = await svc.reconcile({ + intent: 'fetch', + applicationId: 'unused', + stackName: 'reconcile-no-app', + trigger: 'manual', + actor: 'tester', + }); + expect(result.outcome).toBe('unknown'); + }); + + function settledAttempts(applicationId: string): { operation_id: string; after_json: string }[] { + return DatabaseService.getInstance().getDb() + .prepare("SELECT operation_id, after_json FROM gitops_history WHERE application_id = ? AND stage = 'source_reconcile_settled'") + .all(applicationId) as { operation_id: string; after_json: string }[]; + } + + function unsettledAttempts(applicationId: string): { operation_id: string }[] { + return DatabaseService.getInstance().getDb() + .prepare("SELECT operation_id FROM gitops_history started WHERE started.application_id = ? AND started.stage = 'source_reconcile_started' AND NOT EXISTS (SELECT 1 FROM gitops_history settled WHERE settled.application_id = started.application_id AND settled.operation_id = started.operation_id AND settled.stage = 'source_reconcile_settled')") + .all(applicationId) as { operation_id: string }[]; + } + + /** + * Hold the clone open so a second reconcile submission is guaranteed + * to arrive while the first is still executing. Returns the release + * function; calling it lets the clone finish and write its compose + * file. + */ + function gatedClone(): () => void { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + mockGitClone.mockClear(); + mockGitClone.mockImplementation(async (args: { dir: string }) => { + await gate; + const { promises: fsp } = await import('fs'); + const path = await import('path'); + const composeAbs = path.join(args.dir, 'compose.yaml'); + await fsp.mkdir(path.dirname(composeAbs), { recursive: true }); + await fsp.writeFile(composeAbs, 'services:\n x:\n image: alpine\n', 'utf-8'); + }); + return release; + } + + it('durably settles a reconcile attempt for a successful fetch, leaving nothing unsettled', async () => { + const sha = 'e8e8e8e8e8e8e8e8e8e8e8e8e8e8e8e8e8e8e8e8'; + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + const svc = GitSourceService.getInstance(); + await svc.upsert({ + stackName: 'reconcile-durable', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + + try { + const applicationId = liveApp('reconcile-durable')!.id; + const result = await svc.reconcile({ + intent: 'fetch', + applicationId, + stackName: 'reconcile-durable', + trigger: 'manual', + actor: 'tester', + }); + + const settled = settledAttempts(applicationId); + expect(settled).toHaveLength(1); + expect(JSON.parse(settled[0].after_json)).toMatchObject({ outcome: result.outcome, reason: result.reason }); + expect(unsettledAttempts(applicationId)).toHaveLength(0); + } finally { + validateSpy.mockRestore(); + } + }); + + it('reserves and settles an attempt for every normalized trigger kind', async () => { + const stackName = 'reconcile-trigger-matrix'; + const sha = 'e9'.repeat(20); + const triggers: ReconcileTrigger[] = [ + 'manual', + 'api', + 'webhook', + 'poll', + 'retry', + 'config_change', + 'startup', + 'resume', + 'provider_event', + 'schedule', + 'binding_change', + ]; + mockSuccessfulClone({ sha }); + const svc = GitSourceService.getInstance(); + await configureGitSource(stackName); + const applicationId = liveApp(stackName)!.id; + + for (const trigger of triggers) { + await svc.reconcile({ + intent: 'fetch', + applicationId, + stackName, + trigger, + actor: `system:${trigger}`, + ...(trigger === 'webhook' ? { deliveryId: 'trigger-matrix-webhook' } : {}), + }); + } + + const started = DatabaseService.getInstance().getDb() + .prepare("SELECT operation_id, trigger FROM gitops_history WHERE application_id = ? AND stage = 'source_reconcile_started' ORDER BY rowid") + .all(applicationId) as { operation_id: string; trigger: string }[]; + expect(started.map((row) => row.trigger)).toEqual(triggers); + expect(new Set(started.map((row) => row.operation_id)).size).toBe(triggers.length); + expect(started.find((row) => row.trigger === 'webhook')?.operation_id) + .toBe(deliveryKey('webhook', 'fetch', 'trigger-matrix-webhook')); + expect(unsettledAttempts(applicationId)).toHaveLength(0); + }); + + it('threads the reserved attempt\'s operation id into the generation the fetch produces', async () => { + const sha = 'ed'.repeat(20); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + const svc = GitSourceService.getInstance(); + await svc.upsert({ + stackName: 'reconcile-operation-id-threading', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + + try { + const applicationId = liveApp('reconcile-operation-id-threading')!.id; + await svc.reconcile({ + intent: 'fetch', + applicationId, + stackName: 'reconcile-operation-id-threading', + trigger: 'manual', + actor: 'tester', + }); + + const settled = settledAttempts(applicationId); + expect(settled).toHaveLength(1); + const candidateGenerationId = liveApp('reconcile-operation-id-threading')!.candidate_generation_id; + expect(candidateGenerationId).toBeTruthy(); + const generation = GitOpsStore.getInstance().getGeneration(candidateGenerationId!); + expect(generation?.operation_id).toBe(settled[0].operation_id); + } finally { + validateSpy.mockRestore(); + } + }); + + it('threads the reserved attempt\'s operation id into the apply-side transition an apply-intent reconcile produces', async () => { + const sha = 'ee'.repeat(20); + const svc = await seedPending('reconcile-apply-operation-id-threading', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + + try { + const applicationId = liveApp('reconcile-apply-operation-id-threading')!.id; + await svc.reconcile({ + intent: 'apply', + applicationId, + stackName: 'reconcile-apply-operation-id-threading', + trigger: 'manual', + actor: 'tester', + commitSha: sha, + planFingerprint: '', + deploy: false, + }); + + // seedPending's own pull() reserves and settles its own fetch + // attempt now that pull() is wired through reservation too, so + // more than one settled row is expected here; only the + // apply-intent one needs to match the applied-stage transition. + const settled = settledAttempts(applicationId).map((r) => r.operation_id); + const applied = historyOperationIds(applicationId, 'applied'); + expect(applied).toHaveLength(1); + expect(settled).toContain(applied[0]); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('coalesces two concurrent fetch-intent reconciles into one execution, each settling its own durable attempt', async () => { + const newSha = 'e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9'; + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0' }); + const svc = GitSourceService.getInstance(); + await svc.upsert({ + stackName: 'reconcile-coalesce', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + + const releaseClone = gatedClone(); + mockGitLog.mockResolvedValue([{ oid: newSha }]); + + try { + const applicationId = liveApp('reconcile-coalesce')!.id; + const first = svc.reconcile({ + intent: 'fetch', applicationId, stackName: 'reconcile-coalesce', trigger: 'manual', actor: 'tester-a', + }); + const second = svc.reconcile({ + intent: 'fetch', applicationId, stackName: 'reconcile-coalesce', trigger: 'manual', actor: 'tester-b', + }); + releaseClone(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(mockGitClone).toHaveBeenCalledTimes(1); + expect(firstResult).toEqual(secondResult); + + const settled = settledAttempts(applicationId); + expect(settled).toHaveLength(2); + expect(new Set(settled.map((r) => r.operation_id)).size).toBe(2); + expect(unsettledAttempts(applicationId)).toHaveLength(0); + } finally { + validateSpy.mockRestore(); + } + }); + + it('coalesces a concurrent manual pull and a controller-triggered reconcile into one clone, each with its own complete settled history and a follower-to-leader link', async () => { + const newSha = 'f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1'; + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0' }); + const svc = GitSourceService.getInstance(); + await configureGitSource('pull-reconcile-coalesce'); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + + const releaseClone = gatedClone(); + mockGitLog.mockResolvedValue([{ oid: newSha }]); + + try { + const applicationId = liveApp('pull-reconcile-coalesce')!.id; + const generationCountBefore = (DatabaseService.getInstance().getDb() + .prepare('SELECT COUNT(*) AS count FROM gitops_generations WHERE application_id = ?') + .get(applicationId) as { count: number }).count; + const candidateReadyCountBefore = historyOperationIds(applicationId, 'candidate_ready').length; + // Two different producers, one a manual pull() and the other + // a controller poll driving reconcile(), submitting for the + // exact same live application: coalesceKey() does not vary + // by trigger or producer, so this must join into one clone + // rather than each running its own. + const manualPull = svc.pull('pull-reconcile-coalesce'); + const controllerReconcile = svc.reconcile({ + intent: 'fetch', + applicationId, + stackName: 'pull-reconcile-coalesce', + trigger: 'poll', + actor: 'system:source-controller', + }); + releaseClone(); + const [pullResult, reconcileResult] = await Promise.all([manualPull, controllerReconcile]); + + expect(mockGitClone).toHaveBeenCalledTimes(1); + expect(pullResult.commitSha).toBe(newSha); + expect(pullResult.candidateReady).toBe(true); + expect(reconcileResult.outcome).toBe('candidate_already_fetched'); + const generationCountAfter = (DatabaseService.getInstance().getDb() + .prepare('SELECT COUNT(*) AS count FROM gitops_generations WHERE application_id = ?') + .get(applicationId) as { count: number }).count; + expect(generationCountAfter).toBe(generationCountBefore + 1); + expect(historyOperationIds(applicationId, 'candidate_ready')).toHaveLength(candidateReadyCountBefore + 1); + + // Two complete histories: each caller reserved and settled + // its own durable attempt, neither left dangling. + const settled = settledAttempts(applicationId); + expect(settled).toHaveLength(2); + const settledOperationIds = settled.map((r) => r.operation_id); + expect(new Set(settledOperationIds).size).toBe(2); + expect(unsettledAttempts(applicationId)).toHaveLength(0); + + // A follower-to-leader link: exactly one of the two + // reservations recorded that it was made on behalf of the + // other. + const started = DatabaseService.getInstance().getDb() + .prepare("SELECT operation_id, after_json FROM gitops_history WHERE application_id = ? AND stage = 'source_reconcile_started'") + .all(applicationId) as { operation_id: string; after_json: string }[]; + const followerLinks = started + .map((r) => (JSON.parse(r.after_json) as { followerOf?: string }).followerOf) + .filter((followerOf): followerOf is string => followerOf !== undefined); + expect(followerLinks).toHaveLength(1); + expect(settledOperationIds).toContain(followerLinks[0]); + } finally { + validateSpy.mockRestore(); + } + }); + + it('leaves every coalesced attempt unsettled when the leader result is not durable, so recovery gives them one result', async () => { + const stackName = 'reconcile-shared-settlement-failure'; + const sha = 'c7'.repeat(20); + mockSuccessfulClone({ sha }); + const svc = GitSourceService.getInstance(); + await configureGitSource(stackName); + const applicationId = liveApp(stackName)!.id; + const priorStarted = new Set(historyOperationIds(applicationId, 'source_reconcile_started')); + const releaseClone = gatedClone(); + mockGitLog.mockResolvedValue([{ oid: sha }]); + const originalSettle = GitOpsTransitions.prototype.settleReconcileAttempt; + const settleSpy = vi.spyOn(GitOpsTransitions.prototype, 'settleReconcileAttempt') + .mockImplementationOnce(() => { throw new Error('simulated leader settlement failure'); }) + .mockImplementation(function (this: GitOpsTransitions, ...args: Parameters) { + return originalSettle.apply(this, args); + }); + + try { + const manual = svc.pull(stackName); + const controller = svc.reconcile({ + intent: 'fetch', + applicationId, + stackName, + trigger: 'poll', + actor: 'system:source-controller', + }); + releaseClone(); + await Promise.all([manual, controller]); + + const operationIds = historyOperationIds(applicationId, 'source_reconcile_started') + .filter((operationId) => !priorStarted.has(operationId)); + expect(operationIds).toHaveLength(2); + expect(unsettledAttempts(applicationId).map((row) => row.operation_id).sort()) + .toEqual([...operationIds].sort()); + + await svc.suspend(stackName, { actor: 'tester', reason: 'settle through recovery' }); + await svc.recoverUnsettledReconcileAttempts(); + + const recovered = settledAttempts(applicationId) + .filter((row) => operationIds.includes(row.operation_id)); + expect(recovered).toHaveLength(2); + expect(new Set(recovered.map((row) => row.after_json)).size).toBe(1); + expect(JSON.parse(recovered[0].after_json).outcome).toBe('suspended'); + } finally { + settleSpy.mockRestore(); + } + }); + + it.each([ + ['manual pull', true], + ['controller reconcile', false], + ] as const)('settles both callers from one failed shared fetch when %s leads', async (_leader, manualLeads) => { + const stackName = manualLeads + ? 'pull-reconcile-failure-manual-leads' + : 'pull-reconcile-failure-controller-leads'; + mockSuccessfulClone({ sha: 'f2'.repeat(20) }); + const svc = GitSourceService.getInstance(); + await configureGitSource(stackName); + const applicationId = liveApp(stackName)!.id; + + let releaseClone!: () => void; + const cloneGate = new Promise((resolve) => { releaseClone = resolve; }); + mockGitClone.mockClear(); + mockGitClone.mockImplementation(async () => { + await cloneGate; + throw new Error('shared fetch failed'); + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const request = { + intent: 'fetch' as const, + applicationId, + stackName, + trigger: 'poll' as const, + actor: 'system:source-controller', + }; + + try { + const first = manualLeads ? svc.pull(stackName) : svc.reconcile(request); + const second = manualLeads ? svc.reconcile(request) : svc.pull(stackName); + releaseClone(); + const [firstResult, secondResult] = await Promise.allSettled([first, second]); + + const reconcileResult = manualLeads ? secondResult : firstResult; + expect(reconcileResult.status).toBe('fulfilled'); + if (reconcileResult.status !== 'fulfilled') throw reconcileResult.reason; + const settled = settledAttempts(applicationId).map((row) => JSON.parse(row.after_json)); + expect(settled).toHaveLength(2); + expect(settled).toEqual([reconcileResult.value, reconcileResult.value]); + expect(mockGitClone).toHaveBeenCalledTimes(1); + expect(unsettledAttempts(applicationId)).toHaveLength(0); + } finally { + errorSpy.mockRestore(); + } + }); + + it('settles a fetch-intent reconcile durably with the same classified result it returns, even for a pre-transition failure the row does not yet reflect', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: '3'.repeat(40) }); + await configureGitSource('reconcile-pretransition-failure'); + const applicationId = liveApp('reconcile-pretransition-failure')!.id; + // Deletes the config row pullLocked itself checks for, before + // any transition table write, so the row state alone (a + // generic row-derivation, with no notion of this failure) would + // misreport the outcome as an unremarkable "never reconciled" + // rather than the real, classified failure the caller receives. + DatabaseService.getInstance().deleteGitSource('reconcile-pretransition-failure'); + + const result = await svc.reconcile({ + intent: 'fetch', + applicationId, + stackName: 'reconcile-pretransition-failure', + trigger: 'poll', + actor: 'system:source-controller', + }); + + expect(result.outcome).not.toBe('unknown'); + const settled = settledAttempts(applicationId); + expect(settled).toHaveLength(1); + expect(JSON.parse(settled[0].after_json)).toEqual(result); + }); + + it('does not re-execute a redelivered request whose original attempt was reserved but never settled', async () => { + const sha = 'dededededededededededededededededededede'; + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + const svc = GitSourceService.getInstance(); + await configureGitSource('reconcile-orphaned-redelivery'); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + + try { + const applicationId = liveApp('reconcile-orphaned-redelivery')!.id; + const request = { + intent: 'fetch' as const, + applicationId, + stackName: 'reconcile-orphaned-redelivery', + trigger: 'webhook' as const, + actor: 'tester', + deliveryId: 'delivery-orphaned', + }; + await svc.reconcile(request); + // Simulate a crash between reservation and settlement: the + // original attempt's reservation survives, but its + // settlement row never got written, and no in-process + // leader remains for it in this fresh call. + DatabaseService.getInstance().getDb() + .prepare("DELETE FROM gitops_history WHERE application_id = ? AND stage = 'source_reconcile_settled'") + .run(applicationId); + mockGitClone.mockClear(); + + const redeliveryResult = await svc.reconcile(request); + + expect(mockGitClone).not.toHaveBeenCalled(); + expect(redeliveryResult.outcome).not.toBe('unknown'); + } finally { + validateSpy.mockRestore(); + } + }); + + it('reports an unknown redelivery result when durable settlement fails', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ sha: 'd1'.repeat(20) }); + await configureGitSource('reconcile-redelivery-settlement-failure'); + const applicationId = liveApp('reconcile-redelivery-settlement-failure')!.id; + const request: ReconcileRequest & { intent: 'fetch'; deliveryId: string } = { + intent: 'fetch', + applicationId, + stackName: 'reconcile-redelivery-settlement-failure', + trigger: 'webhook', + actor: 'tester', + deliveryId: 'delivery-settlement-failure', + }; + GitOpsTransitions.getInstance().reserveReconcileAttempt(applicationId, { + operationId: deliveryKey('webhook', 'fetch', request.deliveryId), + actor: request.actor, + trigger: request.trigger, + at: Date.now(), + }); + mockGitClone.mockClear(); + const settleSpy = vi.spyOn(GitOpsTransitions.prototype, 'settleReconcileAttempt') + .mockImplementationOnce(() => { throw new Error('simulated redelivery settlement failure'); }); + + try { + const result = await svc.reconcile(request); + + expect(mockGitClone).not.toHaveBeenCalled(); + expect(result).toEqual({ + outcome: 'unknown', + reason: 'This attempt could not be durably resolved.', + nextAction: 'none', + }); + expect(unsettledAttempts(applicationId)).toHaveLength(1); + } finally { + settleSpy.mockRestore(); + } + }); + + it('resolves a redelivery from its own settled history rather than an unrelated leader that happens to be running under the shared coalesce key', async () => { + const sha = 'cececececececececececececececececececece'; + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + const svc = GitSourceService.getInstance(); + await configureGitSource('reconcile-redelivery-vs-unrelated-leader'); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + + try { + const applicationId = liveApp('reconcile-redelivery-vs-unrelated-leader')!.id; + const request = { + intent: 'fetch' as const, + applicationId, + stackName: 'reconcile-redelivery-vs-unrelated-leader', + trigger: 'webhook' as const, + actor: 'tester', + deliveryId: 'delivery-vs-unrelated-leader', + }; + const first = await svc.reconcile(request); + + // A completely unrelated fetch (a plain manual pull, no + // deliveryId) becomes the in-process leader registered + // under this application's shared fetch coalesce key, + // which does not vary by deliveryId or trigger. + const releaseClone = gatedClone(); + mockGitLog.mockResolvedValue([{ oid: 'dfdfdfdfdfdfdfdfdfdfdfdfdfdfdfdfdfdfdfdf' }]); + const unrelatedPull = svc.pull('reconcile-redelivery-vs-unrelated-leader'); + + // A redelivery of the original event arrives while that + // unrelated pull is still running: it must resolve from its + // own settled history, not from the unrelated in-flight + // leader it happens to find under the shared key. + const redelivery = await svc.reconcile(request); + releaseClone(); + await unrelatedPull; + + expect(redelivery).toEqual(first); + } finally { + validateSpy.mockRestore(); + } + }); + + it('does not re-execute a redelivered request carrying the same deliveryId, and returns the original settled result', async () => { + const sha = 'eaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaea'; + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + const svc = GitSourceService.getInstance(); + await svc.upsert({ + stackName: 'reconcile-dedupe', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + + try { + const applicationId = liveApp('reconcile-dedupe')!.id; + const request = { + intent: 'fetch' as const, + applicationId, + stackName: 'reconcile-dedupe', + trigger: 'webhook' as const, + actor: 'tester', + deliveryId: 'delivery-1', + }; + const first = await svc.reconcile(request); + mockGitClone.mockClear(); + const second = await svc.reconcile(request); + + expect(mockGitClone).not.toHaveBeenCalled(); + expect(second).toEqual(first); + expect(settledAttempts(applicationId)).toHaveLength(1); + } finally { + validateSpy.mockRestore(); + } + }); + + it('joins a concurrent redelivery of the same deliveryId to the in-flight leader, returning the leader\'s real result rather than a stale snapshot', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'e0'.repeat(20) }); + await svc.upsert({ + stackName: 'reconcile-concurrent-redelivery', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + + const releaseClone = gatedClone(); + mockGitLog.mockResolvedValue([{ oid: 'e1'.repeat(20) }]); + + try { + const applicationId = liveApp('reconcile-concurrent-redelivery')!.id; + const request = { + intent: 'fetch' as const, + applicationId, + stackName: 'reconcile-concurrent-redelivery', + trigger: 'webhook' as const, + actor: 'tester', + deliveryId: 'delivery-race', + }; + const first = svc.reconcile(request); + const redelivery = svc.reconcile(request); + releaseClone(); + const [firstResult, redeliveryResult] = await Promise.all([first, redelivery]); + + expect(mockGitClone).toHaveBeenCalledTimes(1); + // The redelivery must report the leader's real post-fetch + // outcome, not a snapshot of the row from before the fetch + // ran (which would still show no candidate staged). + expect(redeliveryResult).toEqual(firstResult); + expect(firstResult.outcome).toBe('candidate_already_fetched'); + } finally { + validateSpy.mockRestore(); + } + }); + + it('logs and reports unknown rather than throwing when a settled attempt\'s stored result is corrupted', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'ef'.repeat(20) }); + await svc.upsert({ + stackName: 'reconcile-corrupt-settled', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + + try { + const applicationId = liveApp('reconcile-corrupt-settled')!.id; + const request = { + intent: 'fetch' as const, + applicationId, + stackName: 'reconcile-corrupt-settled', + trigger: 'webhook' as const, + actor: 'tester', + deliveryId: 'delivery-corrupt', + }; + await svc.reconcile(request); + DatabaseService.getInstance().getDb() + .prepare("UPDATE gitops_history SET after_json = ? WHERE application_id = ? AND stage = 'source_reconcile_settled'") + .run('not valid json{{{', applicationId); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await svc.reconcile(request); + + expect(result.outcome).toBe('unknown'); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + } finally { + validateSpy.mockRestore(); + } + }); + + it('joins a same-delivery apply under a different coalesce key to the real in-flight leader rather than settling a stale snapshot', async () => { + const svc = GitSourceService.getInstance(); + const sha = 'a6'.repeat(20); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + await svc.upsert({ + stackName: 'reconcile-apply-delivery-race', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + await svc.pull('reconcile-apply-delivery-race'); + const applicationId = liveApp('reconcile-apply-delivery-race')!.id; + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + let releaseSave!: () => void; + const gate = new Promise((resolve) => { releaseSave = resolve; }); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockImplementation(async () => { await gate; }); + + try { + // Two apply requests sharing a delivery id (so their + // operation ids collide) but differing in commitSha, which + // coalesceKey includes for an apply intent -- so they run + // under different coalesce keys despite the shared + // operation id. + const first = svc.reconcile({ + intent: 'apply', + applicationId, + stackName: 'reconcile-apply-delivery-race', + trigger: 'webhook', + actor: 'tester', + commitSha: sha, + planFingerprint: '', + deploy: false, + deliveryId: 'shared-delivery', + }); + const second = svc.reconcile({ + intent: 'apply', + applicationId, + stackName: 'reconcile-apply-delivery-race', + trigger: 'webhook', + actor: 'tester', + commitSha: 'ff'.repeat(20), + planFingerprint: 'different-fingerprint', + deploy: true, + deliveryId: 'shared-delivery', + }); + releaseSave(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + // The second request must never have run its own apply + // (a different, unstaged commitSha would fail on its own + // terms); it must instead have joined the first's real + // execution and returned its actual result. + expect(secondResult).toEqual(firstResult); + expect(saveSpy).toHaveBeenCalledTimes(1); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('coalesces a concurrent manual apply and webhook apply into one promotion and one normalized result', async () => { + const sha = 'a7'.repeat(20); + const svc = await seedPending('apply-reconcile-coalesce', 'services:\n x:\n image: alpine\n', sha); + const applicationId = liveApp('apply-reconcile-coalesce')!.id; + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + let releaseSave!: () => void; + const saveGate = new Promise((resolve) => { releaseSave = resolve; }); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockImplementation(async () => { await saveGate; }); + const priorSettlements = new Set(settledAttempts(applicationId).map((row) => row.operation_id)); + + try { + const manualApply = svc.apply('apply-reconcile-coalesce', sha, SKIP_PLAN_FINGERPRINT); + const controllerApply = svc.reconcile({ + intent: 'apply', + applicationId, + stackName: 'apply-reconcile-coalesce', + trigger: 'webhook', + actor: 'system:webhook', + commitSha: sha, + planFingerprint: '', + deploy: false, + deliveryId: 'apply-cross-producer', + }); + releaseSave(); + const [manualResult, reconcileResult] = await Promise.all([manualApply, controllerApply]); + + expect(manualResult.applied).toBe(true); + expect(saveSpy).toHaveBeenCalledTimes(1); + const settled = settledAttempts(applicationId) + .filter((row) => !priorSettlements.has(row.operation_id)) + .map((row) => JSON.parse(row.after_json)); + expect(settled).toHaveLength(2); + expect(settled).toEqual([reconcileResult, reconcileResult]); + expect(unsettledAttempts(applicationId)).toHaveLength(0); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('does not let a fingerprint-enforcing manual apply borrow an internal apply that bypasses the fingerprint check', async () => { + const sha = 'b7'.repeat(20); + const svc = await seedPending('apply-fingerprint-mode-isolation', 'services:\n x:\n image: alpine\n', sha); + const applicationId = liveApp('apply-fingerprint-mode-isolation')!.id; + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + let releaseSave!: () => void; + const saveGate = new Promise((resolve) => { releaseSave = resolve; }); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockImplementation(async () => { await saveGate; }); + + try { + const internalApply = svc.reconcile({ + intent: 'apply', + applicationId, + stackName: 'apply-fingerprint-mode-isolation', + trigger: 'webhook', + actor: 'system:webhook', + commitSha: sha, + planFingerprint: 'stale-fingerprint', + deploy: false, + deliveryId: 'apply-fingerprint-mode-isolation', + }); + await vi.waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); + const manualApply = svc.apply('apply-fingerprint-mode-isolation', sha, { + planFingerprint: 'stale-fingerprint', + deploy: false, + }); + releaseSave(); + + await expect(internalApply).resolves.toMatchObject({ outcome: expect.any(String) }); + await expect(manualApply).rejects.toMatchObject({ code: expect.any(String) }); + expect(saveSpy).toHaveBeenCalledTimes(1); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('resolves an apply redelivery from its own history while an unrelated matching apply is running', async () => { + const sha = 'a8'.repeat(20); + const svc = await seedPending('apply-redelivery-vs-unrelated-leader', 'services:\n x:\n image: alpine\n', sha); + const applicationId = liveApp('apply-redelivery-vs-unrelated-leader')!.id; + const originalRequest: ReconcileRequest & { intent: 'apply' } = { + intent: 'apply' as const, + applicationId, + stackName: 'apply-redelivery-vs-unrelated-leader', + trigger: 'webhook' as const, + actor: 'tester', + commitSha: sha, + planFingerprint: '', + deploy: true, + deliveryId: 'original-delivery', + }; + const originalOperationId = deliveryKey('webhook', 'apply', 'original-delivery'); + const originalResult = { + outcome: 'recovery_required' as const, + reason: 'The source applied, but the deploy failed: first delivery deploy failed', + nextAction: 'view_target_results' as const, + }; + const tx = GitOpsTransitions.getInstance(); + const envelope = { operationId: originalOperationId, actor: 'tester', trigger: 'webhook', at: Date.now() }; + tx.reserveReconcileAttempt(applicationId, envelope); + tx.settleReconcileAttempt(applicationId, envelope, originalResult); + + type ApplyExecution = { + status: 'fulfilled'; + value: { applied: boolean; deployed: boolean }; + result: { outcome: 'no_source_change'; reason: string; nextAction: 'none' }; + }; + type ApplyCompletion = { execution: ApplyExecution; settled: boolean }; + let releaseLeader!: (completion: ApplyCompletion) => void; + const leaderPromise = new Promise((resolve) => { releaseLeader = resolve; }); + const inFlightApplies = (svc as unknown as { + inFlightApplies: Map }>; + }).inFlightApplies; + const executionKey = `${coalesceKey(originalRequest)}:fingerprint-optional`; + inFlightApplies.set(executionKey, { + operationId: 'unrelated-operation', + promise: leaderPromise, + }); + + try { + const redeliveryResult = await svc.reconcile(originalRequest); + expect(redeliveryResult).toEqual(originalResult); + } finally { + inFlightApplies.delete(executionKey); + releaseLeader({ + settled: true, + execution: { + status: 'fulfilled', + value: { applied: true, deployed: true }, + result: { outcome: 'no_source_change', reason: 'Unrelated leader finished.', nextAction: 'none' }, + }, + }); + } + }); + }); + + describe('dispatchAcceptedGeneration', () => { + const directContext = { targetMode: 'direct', nodeId: null, bindingRevision: null } as const; + const manualDispatch = { trigger: 'manual', actor: 'tester' } as const; + + async function acceptedGenerationFor(stackName: string) { + const { buildAcceptedGeneration } = await import('../services/gitops/handoff'); + const app = liveApp(stackName)!; + const row = GitOpsStore.getInstance().getGeneration(app.candidate_generation_id!)!; + return buildAcceptedGeneration(row); + } + + it('dispatches a direct-mode generation by driving the existing apply path', async () => { + const sha = 'd1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1'; + const svc = await seedPending('dispatch-direct', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + + try { + const generation = await acceptedGenerationFor('dispatch-direct'); + const result = await svc.dispatchAcceptedGeneration(generation, directContext, manualDispatch); + expect(result).toEqual({ status: 'dispatched' }); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('blocks a blueprint-mode generation by delegating to BlueprintTargetAdapter', async () => { + const sha = 'd2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2'; + const svc = await seedPending('dispatch-blueprint', 'services:\n x:\n image: alpine\n', sha); + const generation = await acceptedGenerationFor('dispatch-blueprint'); + + const result = await svc.dispatchAcceptedGeneration( + generation, + { targetMode: 'blueprint', nodeId: 1, bindingRevision: 'rev-1' }, + manualDispatch, + ); + + expect(result).toEqual({ + status: 'blocked', + reason: 'Blueprint rollout orchestration is not yet implemented.', + }); + }); + + it('blocks and forwards the reason when the underlying apply fails', async () => { + const sha = 'd3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3'; + const svc = await seedPending('dispatch-apply-fails', 'services:\n x:\n image: alpine\n', sha); + const generation = await acceptedGenerationFor('dispatch-apply-fails'); + const staleGeneration = { ...generation, commitSha: 'ffffffffffffffffffffffffffffffffffffffff' }; + + const result = await svc.dispatchAcceptedGeneration(staleGeneration, directContext, manualDispatch); + + expect(result).toEqual({ + status: 'blocked', + reason: expect.stringMatching(/pending commit has changed/i), + }); + }); + + it('blocks a direct-mode dispatch when the generation names an application that no longer exists', async () => { + const sha = 'd4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4'; + const svc = await seedPending('dispatch-no-stack', 'services:\n x:\n image: alpine\n', sha); + const generation = await acceptedGenerationFor('dispatch-no-stack'); + const orphanGeneration = { ...generation, applicationId: 'no-such-application' }; + + const result = await svc.dispatchAcceptedGeneration(orphanGeneration, directContext, manualDispatch); + + expect(result).toEqual({ + status: 'blocked', + reason: expect.stringMatching(/no direct stack is bound/i), + }); + }); + + it('honors an auto_deploy_on_apply source setting by requesting a deploy on dispatch', async () => { + const sha = 'd5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5'; + const svc = await seedPending('dispatch-auto-deploy', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const { ComposeService } = await import('../services/ComposeService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue({ recoveryId: null, deployedGenerationId: null }); + DatabaseService.getInstance().getDb() + .prepare('UPDATE stack_git_sources SET auto_deploy_on_apply = 1 WHERE stack_name = ?') + .run('dispatch-auto-deploy'); + + try { + const generation = await acceptedGenerationFor('dispatch-auto-deploy'); + const result = await svc.dispatchAcceptedGeneration(generation, directContext, manualDispatch); + expect(result).toEqual({ status: 'dispatched' }); + expect(deploySpy).toHaveBeenCalled(); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + deploySpy.mockRestore(); + } + }); + + it('blocks, rather than reporting dispatched, when an auto_deploy_on_apply dispatch applies but the deploy fails', async () => { + const sha = 'd6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6'; + const svc = await seedPending('dispatch-auto-deploy-fails', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const { ComposeService } = await import('../services/ComposeService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockRejectedValue( + new Error('compose up failed: docker unavailable'), + ); + DatabaseService.getInstance().getDb() + .prepare('UPDATE stack_git_sources SET auto_deploy_on_apply = 1 WHERE stack_name = ?') + .run('dispatch-auto-deploy-fails'); + + try { + const generation = await acceptedGenerationFor('dispatch-auto-deploy-fails'); + const result = await svc.dispatchAcceptedGeneration(generation, directContext, manualDispatch); + expect(result).toEqual({ status: 'blocked', reason: expect.stringMatching(/deploy/i) }); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + deploySpy.mockRestore(); + } + }); + }); + + describe('suspend / resume / retry', () => { + it('suspends an active source and reflects it in the reconcile result', async () => { + const svc = await seedPending('suspend-basic', 'services:\n x:\n image: alpine\n', 's1s1s1s1s1s1s1s1s1s1s1s1s1s1s1s1s1s1s1s1'); + + const result = await svc.suspend('suspend-basic', { actor: 'tester', reason: 'maintenance window' }); + + expect(result.outcome).toBe('suspended'); + expect(result.reason).toMatch(/maintenance window/i); + const app = liveApp('suspend-basic'); + expect(app?.suspended_at).toBeTruthy(); + expect(app?.source_suspended_reason).toBe('maintenance window'); + }); + + it('suspends with a default reason when none is given', async () => { + const svc = await seedPending('suspend-default-reason', 'services:\n x:\n image: alpine\n', 's2s2s2s2s2s2s2s2s2s2s2s2s2s2s2s2s2s2s2s2'); + + const result = await svc.suspend('suspend-default-reason', { actor: 'tester' }); + + expect(result.outcome).toBe('suspended'); + expect(liveApp('suspend-default-reason')?.source_suspended_reason).toBe('Suspended by operator.'); + }); + + it('falls back to the default reason when only whitespace is given', async () => { + const svc = await seedPending('suspend-whitespace-reason', 'services:\n x:\n image: alpine\n', 's9s9s9s9s9s9s9s9s9s9s9s9s9s9s9s9s9s9s9s9'); + + await svc.suspend('suspend-whitespace-reason', { actor: 'tester', reason: ' ' }); + + expect(liveApp('suspend-whitespace-reason')?.source_suspended_reason).toBe('Suspended by operator.'); + }); + + it('reports unknown when suspending a stack with no GitOps application', async () => { + const result = await GitSourceService.getInstance().suspend('suspend-no-app', { actor: 'tester' }); + + expect(result.outcome).toBe('unknown'); + }); + + it('surfaces a real error, rather than a silent no-op, when suspending an application that is not live', async () => { + const config: DirectSourceConfig = { + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + }; + GitOpsStore.getInstance().insertApplication(buildDirectApplicationRow({ + id: newGitOpsId(), + stackName: 'suspend-creating-app', + config, + identity: directSourceIdentity(config), + lifecycleStatus: 'creating', + at: Date.now(), + })); + + await expect(GitSourceService.getInstance().suspend('suspend-creating-app', { actor: 'tester' })) + .rejects.toMatchObject({ code: 'OPERATION_IN_FLIGHT' }); + }); + + it('actually stops a fetch, not just the projected outcome, once suspended', async () => { + const svc = await seedPending('suspend-blocks-fetch', 'services:\n x:\n image: alpine\n', 's6s6s6s6s6s6s6s6s6s6s6s6s6s6s6s6s6s6s6s6'); + await svc.suspend('suspend-blocks-fetch', { actor: 'tester', reason: 'pausing' }); + mockGitClone.mockClear(); + + await expect(svc.pull('suspend-blocks-fetch')).rejects.toMatchObject({ code: 'OPERATION_IN_FLIGHT' }); + + expect(mockGitClone).not.toHaveBeenCalled(); + }); + + it('actually stops an apply once suspended, even for a pending commit fetched before suspension', async () => { + const sha = 's7s7s7s7s7s7s7s7s7s7s7s7s7s7s7s7s7s7s7s7'; + const svc = await seedPending('suspend-blocks-apply', 'services:\n x:\n image: alpine\n', sha); + await svc.suspend('suspend-blocks-apply', { actor: 'tester', reason: 'pausing' }); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + + try { + await expect(svc.apply('suspend-blocks-apply', sha, SKIP_PLAN_FINGERPRINT)) + .rejects.toMatchObject({ code: 'OPERATION_IN_FLIGHT' }); + expect(saveSpy).not.toHaveBeenCalled(); + } finally { + saveSpy.mockRestore(); + } + }); + + it('a webhook delivery to a suspended source is skipped, not reported as a failed pull', async () => { + const svc = await seedPending('suspend-webhook', 'services:\n x:\n image: alpine\n', 'scscscscscscscscscscscscscscscscscscscsc'); + await svc.suspend('suspend-webhook', { actor: 'tester', reason: 'pausing' }); + mockGitClone.mockClear(); + const activitySpy = vi.spyOn(DatabaseService.getInstance(), 'addNotificationHistory'); + + try { + const result = await svc.handleWebhookPull('suspend-webhook', true); + + expect(result.status).toBe('skipped'); + expect(mockGitClone).not.toHaveBeenCalled(); + expect(activitySpy).not.toHaveBeenCalled(); + } finally { + activitySpy.mockRestore(); + } + }); + + it('resumes a suspended source', async () => { + const svc = await seedPending('resume-basic', 'services:\n x:\n image: alpine\n', 's3s3s3s3s3s3s3s3s3s3s3s3s3s3s3s3s3s3s3s3'); + await svc.suspend('resume-basic', { actor: 'tester', reason: 'pausing' }); + + const result = await svc.resume('resume-basic', { actor: 'tester' }); + + expect(result.outcome).not.toBe('suspended'); + const app = liveApp('resume-basic'); + expect(app?.suspended_at).toBeNull(); + expect(app?.source_suspended_reason).toBeNull(); + }); + + it('resuming a source that is not suspended is a harmless no-op, not an error', async () => { + const svc = await seedPending('resume-noop', 'services:\n x:\n image: alpine\n', 's8s8s8s8s8s8s8s8s8s8s8s8s8s8s8s8s8s8s8s8'); + + const result = await svc.resume('resume-noop', { actor: 'tester' }); + + expect(result.outcome).toBe('candidate_already_fetched'); + }); + + it('pulling and applying again succeeds once a suspended source is resumed', async () => { + const sha = 'sbsbsbsbsbsbsbsbsbsbsbsbsbsbsbsbsbsbsbsb'; + const svc = await seedPending('suspend-resume-roundtrip', 'services:\n x:\n image: alpine\n', sha); + await svc.suspend('suspend-resume-roundtrip', { actor: 'tester', reason: 'pausing' }); + await expect(svc.pull('suspend-resume-roundtrip')).rejects.toMatchObject({ code: 'OPERATION_IN_FLIGHT' }); + await expect(svc.apply('suspend-resume-roundtrip', sha, SKIP_PLAN_FINGERPRINT)) + .rejects.toMatchObject({ code: 'OPERATION_IN_FLIGHT' }); + + await svc.resume('suspend-resume-roundtrip', { actor: 'tester' }); + const pullResult = await svc.pull('suspend-resume-roundtrip'); + expect(pullResult.candidateReady).toBe(true); + + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + try { + const applyResult = await svc.apply('suspend-resume-roundtrip', sha, SKIP_PLAN_FINGERPRINT); + expect(applyResult.applied).toBe(true); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('retries by driving a fresh fetch-intent reconcile', async () => { + const svc = await seedPending('retry-basic', 'services:\n x:\n image: alpine\n', 's5s5s5s5s5s5s5s5s5s5s5s5s5s5s5s5s5s5s5s5'); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const reconcileSpy = vi.spyOn(svc, 'reconcile'); + + try { + const result = await svc.retry('retry-basic', { actor: 'tester' }); + expect(result.outcome).toBe('candidate_already_fetched'); + expect(reconcileSpy).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'retry', intent: 'fetch' })); + } finally { + validateSpy.mockRestore(); + reconcileSpy.mockRestore(); + } + }); + + it('reports unknown when retrying a stack with no GitOps application', async () => { + const result = await GitSourceService.getInstance().retry('retry-no-app', { actor: 'tester' }); + + expect(result.outcome).toBe('unknown'); + }); + + it('retrying a suspended source reports suspended, not a generic unknown', async () => { + const svc = await seedPending('retry-while-suspended', 'services:\n x:\n image: alpine\n', 'sasasasasasasasasasasasasasasasasasasasa'); + await svc.suspend('retry-while-suspended', { actor: 'tester', reason: 'pausing' }); + + const result = await svc.retry('retry-while-suspended', { actor: 'tester' }); + + expect(result.outcome).toBe('suspended'); + expect(result.nextAction).toBe('resume'); + }); + }); + it('throws when pending has been cleared between pull and apply', async () => { const svc = await seedPending('apply-cleared', 'services:\n x:\n image: alpine\n', 'aaaa111aaaa111aaaa111aaaa111aaaa111aaaa1'); DatabaseService.getInstance().clearGitSourcePending('apply-cleared'); @@ -2114,6 +4690,13 @@ describe('GitSourceService.apply', () => { const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockRejectedValue( new Error('compose up failed: docker unavailable'), ); + const applicationId = liveApp('apply-deploy-fail')!.id; + const priorSettlements = new Set( + DatabaseService.getInstance().getDb() + .prepare("SELECT operation_id FROM gitops_history WHERE application_id = ? AND stage = 'source_reconcile_settled'") + .all(applicationId) + .map((row) => (row as { operation_id: string }).operation_id), + ); try { // Assert the return SHAPE: apply must not throw, deployError must @@ -2128,6 +4711,16 @@ describe('GitSourceService.apply', () => { const row = DatabaseService.getInstance().getGitSource('apply-deploy-fail'); expect(row?.last_applied_commit_sha).toBe(sha); expect(row?.pending_commit_sha).toBeNull(); + + const settled = DatabaseService.getInstance().getDb() + .prepare("SELECT operation_id, after_json FROM gitops_history WHERE application_id = ? AND stage = 'source_reconcile_settled'") + .all(applicationId) + .filter((item) => !priorSettlements.has((item as { operation_id: string }).operation_id)) as { after_json: string }[]; + expect(settled).toHaveLength(1); + expect(JSON.parse(settled[0].after_json)).toMatchObject({ + outcome: 'recovery_required', + nextAction: 'view_target_results', + }); } finally { validateSpy.mockRestore(); saveSpy.mockRestore(); @@ -2135,6 +4728,80 @@ describe('GitSourceService.apply', () => { } }); + describe('cache invalidation and post-deploy scan', () => { + beforeEach(() => { + mockInvalidateNodeCaches.mockClear(); + mockTriggerPostDeployScan.mockClear(); + }); + + it('invalidates caches once and does not scan for an apply-only commit', async () => { + const sha = 'f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0'; + const svc = await seedPending('apply-only-cache', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + + try { + const result = await svc.apply('apply-only-cache', sha, { deploy: false, ...skipFingerprint }); + expect(result.applied).toBe(true); + expect(result.deployed).toBe(false); + expect(mockInvalidateNodeCaches).toHaveBeenCalledTimes(1); + expect(mockTriggerPostDeployScan).not.toHaveBeenCalled(); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('invalidates caches once and scans once for a successful apply-and-deploy', async () => { + const sha = 'f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1'; + const svc = await seedPending('apply-deploy-scan', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const { ComposeService } = await import('../services/ComposeService'); + const { HealthGateService } = await import('../services/HealthGateService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue({ recoveryId: null, deployedGenerationId: null }); + const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'beginStack').mockReturnValue('gate-scan'); + + try { + const result = await svc.apply('apply-deploy-scan', sha, { deploy: true, ...skipFingerprint }); + expect(result.deployed).toBe(true); + expect(mockInvalidateNodeCaches).toHaveBeenCalledTimes(1); + expect(mockTriggerPostDeployScan).toHaveBeenCalledTimes(1); + expect(mockTriggerPostDeployScan).toHaveBeenCalledWith('apply-deploy-scan', expect.any(Number)); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + deploySpy.mockRestore(); + beginSpy.mockRestore(); + } + }); + + it('invalidates caches once but does not scan when the deploy fails', async () => { + const sha = 'f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2'; + const svc = await seedPending('apply-deploy-fail-scan', 'services:\n x:\n image: alpine\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const { ComposeService } = await import('../services/ComposeService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockRejectedValue( + new Error('compose up failed: docker unavailable'), + ); + + try { + const result = await svc.apply('apply-deploy-fail-scan', sha, { deploy: true, ...skipFingerprint }); + expect(result.deployed).toBe(false); + expect(mockInvalidateNodeCaches).toHaveBeenCalledTimes(1); + expect(mockTriggerPostDeployScan).not.toHaveBeenCalled(); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + deploySpy.mockRestore(); + } + }); + }); + it('refuses the first complete-project apply when an unowned local file collides (audit round 9 B-1)', async () => { const sha = '9999aaaa9999aaaa9999aaaa9999aaaa9999aaaa'; mockSuccessfulClone({ @@ -2250,6 +4917,306 @@ describe('GitSourceService.apply', () => { }); }); +describe('GitSourceService.recoverUnsettledReconcileAttempts', () => { + it('settles a follower from its leader\'s stored result rather than deriving independently, when only the follower is unsettled', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'a1'.repeat(20) }); + await svc.upsert({ + stackName: 'recover-leader-follower', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('recover-leader-follower')!.id; + const tx = GitOpsTransitions.getInstance(); + + tx.reserveReconcileAttempt(applicationId, { operationId: 'leader-op', actor: 'tester', trigger: 'manual', at: Date.now() }); + tx.reserveReconcileAttempt( + applicationId, + { operationId: 'follower-op', actor: 'tester', trigger: 'manual', at: Date.now() + 1 }, + 'leader-op', + ); + // The leader settled with a specific result before the crash that + // orphaned only the follower. This must not match whatever + // independent derivation from current row state would produce, or + // the test cannot tell a real leader-link recovery from a + // coincidence. + tx.settleReconcileAttempt(applicationId, { operationId: 'leader-op', actor: 'tester', trigger: 'manual', at: Date.now() }, { + outcome: 'blocked', + reason: 'a specific reason only the leader would know', + nextAction: 'resolve_conflict', + }); + + await svc.recoverUnsettledReconcileAttempts(); + + const followerSettled = GitOpsStore.getInstance().getSettledAttempt(applicationId, 'follower-op'); + expect(followerSettled).toBeDefined(); + expect(JSON.parse(followerSettled!.after_json)).toMatchObject({ + outcome: 'blocked', + reason: 'a specific reason only the leader would know', + nextAction: 'resolve_conflict', + }); + }); + + it('leaves no unsettled follower after restart when both leader and follower crashed before settling', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'a2'.repeat(20) }); + await svc.upsert({ + stackName: 'recover-both-unsettled', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('recover-both-unsettled')!.id; + const tx = GitOpsTransitions.getInstance(); + tx.reserveReconcileAttempt(applicationId, { operationId: 'leader-op-2', actor: 'tester', trigger: 'manual', at: Date.now() }); + tx.reserveReconcileAttempt( + applicationId, + { operationId: 'follower-op-2', actor: 'tester', trigger: 'manual', at: Date.now() + 1 }, + 'leader-op-2', + ); + + await svc.recoverUnsettledReconcileAttempts(); + + expect(GitOpsStore.getInstance().getSettledAttempt(applicationId, 'leader-op-2')).toBeDefined(); + expect(GitOpsStore.getInstance().getSettledAttempt(applicationId, 'follower-op-2')).toBeDefined(); + expect(GitOpsStore.getInstance().listUnsettledReconcileAttempts().some((r) => r.application_id === applicationId)).toBe(false); + }); + + it('does not settle a follower independently when its leader also fails to settle in this same recovery pass', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'a5'.repeat(20) }); + await svc.upsert({ + stackName: 'recover-leader-also-fails', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('recover-leader-also-fails')!.id; + const tx = GitOpsTransitions.getInstance(); + tx.reserveReconcileAttempt(applicationId, { operationId: 'leader-op-3', actor: 'tester', trigger: 'manual', at: Date.now() }); + tx.reserveReconcileAttempt( + applicationId, + { operationId: 'follower-op-3', actor: 'tester', trigger: 'manual', at: Date.now() + 1 }, + 'leader-op-3', + ); + // The application vanishes before recovery runs, so the leader's + // own settlement (pass 1, independent branch) will itself throw + // and fail, not just "not yet have happened." + DatabaseService.getInstance().getDb().prepare('DELETE FROM gitops_applications WHERE id = ?').run(applicationId); + + await svc.recoverUnsettledReconcileAttempts(); + + // Neither settles: the follower must not be given an independently + // guessed result while its leader's own fate is still unresolved, + // even though the leader failed rather than merely being deferred. + expect(GitOpsStore.getInstance().getSettledAttempt(applicationId, 'leader-op-3')).toBeUndefined(); + expect(GitOpsStore.getInstance().getSettledAttempt(applicationId, 'follower-op-3')).toBeUndefined(); + }); + + it.each([ + ['malformed JSON', '{invalid'], + ['a non-string follower link', JSON.stringify({ followerOf: 123 })], + ])('leaves a follower unsettled when its reservation contains %s', async (_caseName, corruptAfterJson) => { + const svc = GitSourceService.getInstance(); + const stackName = `recover-corrupt-follower-${crypto.randomUUID()}`; + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'a6'.repeat(20) }); + await svc.upsert({ + stackName, + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication(stackName)!.id; + const tx = GitOpsTransitions.getInstance(); + tx.reserveReconcileAttempt(applicationId, { operationId: 'corrupt-leader', actor: 'tester', trigger: 'manual', at: Date.now() }); + tx.reserveReconcileAttempt( + applicationId, + { operationId: 'corrupt-follower', actor: 'tester', trigger: 'manual', at: Date.now() + 1 }, + 'corrupt-leader', + ); + DatabaseService.getInstance().getDb() + .prepare("UPDATE gitops_history SET after_json = ? WHERE application_id = ? AND operation_id = ? AND stage = 'source_reconcile_started'") + .run(corruptAfterJson, applicationId, 'corrupt-follower'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + await svc.recoverUnsettledReconcileAttempts(); + + expect(GitOpsStore.getInstance().getSettledAttempt(applicationId, 'corrupt-leader')).toBeDefined(); + expect(GitOpsStore.getInstance().getSettledAttempt(applicationId, 'corrupt-follower')).toBeUndefined(); + const unsettled = GitOpsStore.getInstance().listUnsettledReconcileAttempts(); + expect(unsettled.some((row) => row.application_id === applicationId && row.operation_id === 'corrupt-follower')).toBe(true); + } finally { + consoleSpy.mockRestore(); + } + }); + + it('drains the full backlog across multiple pages even when an earlier row can never be recovered', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'a3'.repeat(20) }); + await svc.upsert({ + stackName: 'recover-paginated-unrecoverable', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const unrecoverableAppId = GitOpsStore.getInstance().getLiveDirectApplication('recover-paginated-unrecoverable')!.id; + const tx = GitOpsTransitions.getInstance(); + // The oldest unsettled row's application is gone, so it can never + // settle. With a page size of 1, a query that keeps returning "the + // oldest still-unsettled row" would return only this one forever. + tx.reserveReconcileAttempt(unrecoverableAppId, { operationId: 'op-unrecoverable', actor: 'tester', trigger: 'manual', at: Date.now() }); + DatabaseService.getInstance().getDb().prepare('DELETE FROM gitops_applications WHERE id = ?').run(unrecoverableAppId); + + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'a4'.repeat(20) }); + await svc.upsert({ + stackName: 'recover-paginated-recoverable', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const recoverableAppId = GitOpsStore.getInstance().getLiveDirectApplication('recover-paginated-recoverable')!.id; + tx.reserveReconcileAttempt(recoverableAppId, { operationId: 'op-recoverable', actor: 'tester', trigger: 'manual', at: Date.now() + 1 }); + + await svc.recoverUnsettledReconcileAttempts(1); + + expect(GitOpsStore.getInstance().getSettledAttempt(recoverableAppId, 'op-recoverable')).toBeDefined(); + }); + + it('settles an attempt left unsettled by a crash, without re-executing a fetch', async () => { + const sha = 'eb'.repeat(20); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha }); + const svc = GitSourceService.getInstance(); + await svc.upsert({ + stackName: 'recover-unsettled', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('recover-unsettled')!.id; + GitOpsTransitions.getInstance().reserveReconcileAttempt(applicationId, { + operationId: 'orphaned-op-1', actor: 'tester', trigger: 'poll', at: Date.now(), + }); + mockGitClone.mockClear(); + + await svc.recoverUnsettledReconcileAttempts(); + + expect(mockGitClone).not.toHaveBeenCalled(); + const settled = GitOpsStore.getInstance().getSettledAttempt(applicationId, 'orphaned-op-1'); + expect(settled).toBeDefined(); + }); + + it('leaves a different application unaffected when only one has an unsettled attempt', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'ec'.repeat(20) }); + await svc.upsert({ + stackName: 'recover-clean', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const applicationId = GitOpsStore.getInstance().getLiveDirectApplication('recover-clean')!.id; + + await expect(svc.recoverUnsettledReconcileAttempts()).resolves.toBeUndefined(); + + expect(GitOpsStore.getInstance().listUnsettledReconcileAttempts().some((r) => r.application_id === applicationId)).toBe(false); + }); + + it('does not let one attempt whose application vanished block recovery of another, older, still-real attempt', async () => { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'ed'.repeat(20) }); + await svc.upsert({ + stackName: 'recover-poisoned', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const poisonedAppId = GitOpsStore.getInstance().getLiveDirectApplication('recover-poisoned')!.id; + GitOpsTransitions.getInstance().reserveReconcileAttempt(poisonedAppId, { + operationId: 'poisoned-op-1', actor: 'tester', trigger: 'poll', at: Date.now(), + }); + // Simulate the application row vanishing between listing and processing. + DatabaseService.getInstance().getDb().prepare('DELETE FROM gitops_applications WHERE id = ?').run(poisonedAppId); + + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: 'ee'.repeat(20) }); + await svc.upsert({ + stackName: 'recover-healthy', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const healthyAppId = GitOpsStore.getInstance().getLiveDirectApplication('recover-healthy')!.id; + GitOpsTransitions.getInstance().reserveReconcileAttempt(healthyAppId, { + operationId: 'healthy-op-1', actor: 'tester', trigger: 'poll', at: Date.now(), + }); + + await expect(svc.recoverUnsettledReconcileAttempts()).resolves.toBeUndefined(); + + expect(GitOpsStore.getInstance().getSettledAttempt(healthyAppId, 'healthy-op-1')).toBeDefined(); + }); +}); + describe('GitSourceService DB normalization (compose_paths back-compat)', () => { it('reads back [compose_path] when a row stores compose_paths as null (legacy)', async () => { mockSuccessfulClone({ composePath: 'stacks/web/compose.yaml' }); @@ -2619,6 +5586,7 @@ describe('GitSourceService managed-area lifecycle', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -2674,6 +5642,7 @@ describe('GitSourceService managed-area lifecycle', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -2814,6 +5783,7 @@ describe('GitSourceService managed-area lifecycle', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -2845,6 +5815,7 @@ describe('GitSourceService managed-area lifecycle', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -2867,27 +5838,8 @@ describe('GitSourceService legacy pending apply (migration path)', () => { const { FileSystemService } = await import('../services/FileSystemService'); const fsSvc = FileSystemService.getInstance(); await fsSvc.createStack('legacy-apply'); - db.upsertGitSource({ - stack_name: 'legacy-apply', - repo_url: 'https://github.com/example/repo.git', - branch: 'main', - compose_path: 'compose.yaml', - compose_paths: ['compose.yaml'], - context_dir: null, - sync_env: false, - env_path: null, - auth_type: 'none', - encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, - auto_apply_on_webhook: false, - auto_deploy_on_apply: false, - last_applied_commit_sha: null, - last_applied_content_hash: null, - pending_commit_sha: sha, - pending_compose_content: null, - pending_env_content: null, - pending_fetched_at: null, - last_debounce_at: null, - }); + mockSuccessfulClone({ sha }); + await configureGitSource('legacy-apply'); // Seed the v2 blob directly, as a pre-upgrade row would carry it. const svcPriv = svc as unknown as { crypto: { encrypt(s: string): string } }; db.setGitSourcePending('legacy-apply', sha, svcPriv.crypto.encrypt(JSON.stringify({ v: 2, files: [{ path: 'compose.yaml', content: 'services:\n web:\n image: nginx\n' }], contextDir: null })), null); @@ -3145,27 +6097,8 @@ describe('GitSourceService classified plan fingerprint', () => { const db = DatabaseService.getInstance(); const { FileSystemService } = await import('../services/FileSystemService'); await FileSystemService.getInstance().createStack('plan-unavail'); - db.upsertGitSource({ - stack_name: 'plan-unavail', - repo_url: 'https://github.com/example/repo.git', - branch: 'main', - compose_path: 'compose.yaml', - compose_paths: ['compose.yaml'], - context_dir: null, - sync_env: false, - env_path: null, - auth_type: 'none', - encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, - auto_apply_on_webhook: false, - auto_deploy_on_apply: false, - last_applied_commit_sha: null, - last_applied_content_hash: null, - pending_commit_sha: sha, - pending_compose_content: null, - pending_env_content: null, - pending_fetched_at: null, - last_debounce_at: null, - }); + mockSuccessfulClone({ sha }); + await configureGitSource('plan-unavail'); const svcPriv = svc as unknown as { crypto: { encrypt(s: string): string } }; db.setGitSourcePending( 'plan-unavail', @@ -3236,3 +6169,180 @@ function seedDirectCandidate(stackName: string): { appId: string; generationId: GitOpsTransitions.getInstance().candidateReady(appId, generationId, false, testEnvelope()); return { appId, generationId }; } + +// ── sweepOrphans claimant fixtures ───────────────────────────────────── + +/** A deterministic 40-char hex sha derived from a short seed. */ +function shaFromSeed(seed: string): string { + return seed.repeat(40).slice(0, 40); +} + +/** + * Create a Git-backed stack, pull one update into it, and backdate the + * resulting candidate directory past the orphan-candidate age threshold so a + * claimant-blind sweep would reap it as stale. Each test then arranges only + * the claimant pointers it exercises before running the sweep. + */ +async function stageStaleCandidate( + stackName: string, + shaSeed: string, +): Promise<{ appId: string; generationId: string; candidateAbs: string }> { + const svc = GitSourceService.getInstance(); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine\n', sha: shaFromSeed(shaSeed) }); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + try { + await svc.createStackFromGit({ + stackName, + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + token: null, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + mockSuccessfulClone({ compose: 'services:\n x:\n image: alpine:2\n', sha: shaFromSeed(`${shaSeed}a`) }); + await svc.pull(stackName); + } finally { + validateSpy.mockRestore(); + } + const store = GitOpsStore.getInstance(); + const app = store.getLiveDirectApplication(stackName)!; + expect(app.candidate_generation_id).toBeTruthy(); + const generation = store.getGeneration(app.candidate_generation_id!)!; + const candidateAbs = path.join(stackManagedRoot(stackName), generation.candidate_dir); + expect(fs.existsSync(candidateAbs)).toBe(true); + const staleMtime = (Date.now() - 25 * 60 * 60 * 1000) / 1000; + fs.utimesSync(candidateAbs, staleMtime, staleMtime); + return { appId: app.id, generationId: generation.id, candidateAbs }; +} + +/** Drop the application row's own pointer at its staged candidate. */ +function clearCandidatePointer(appId: string): void { + DatabaseService.getInstance().getDb() + .prepare('UPDATE gitops_applications SET candidate_generation_id = NULL WHERE id = ?') + .run(appId); +} + +/** Drop the pending fetch record, the claimant that is independent of the application row. */ +function clearPendingFetch(stackName: string): void { + DatabaseService.getInstance().getDb() + .prepare('UPDATE stack_git_sources SET pending_commit_sha = NULL, pending_compose_content = NULL WHERE stack_name = ?') + .run(stackName); +} + +describe('GitSourceService.sweepOrphans candidate claimant preservation', () => { + it('preserves a stale, complete candidate directory still referenced by the live application\'s candidate_generation_id', async () => { + const { candidateAbs } = await stageStaleCandidate('sweep-claims-candidate', 'c1'); + + await GitSourceService.getInstance().sweepOrphans(); + + expect(fs.existsSync(candidateAbs)).toBe(true); + }); + + it('still reaps a stale, complete candidate directory nothing on the application row references', async () => { + const { appId, candidateAbs } = await stageStaleCandidate('sweep-reaps-unclaimed', 'c2'); + // Nothing on the row, and nothing pending, points at this generation + // any more: the exact "leftover from an earlier attempt" case the + // sweep exists to clean up. + clearCandidatePointer(appId); + clearPendingFetch('sweep-reaps-unclaimed'); + + await GitSourceService.getInstance().sweepOrphans(); + + expect(fs.existsSync(candidateAbs)).toBe(false); + }); + + it('preserves a stale, complete candidate directory referenced only by the pending fetch record, with no generation row yet', async () => { + const { appId, candidateAbs } = await stageStaleCandidate('sweep-claims-pending-only', 'c3'); + // Simulate the row-level pointer being gone (the exact + // fetchedInvalid/no-live-application gap where a generation may never + // have existed at all) while the pending fetch record, written + // independently, still names this candidate. + clearCandidatePointer(appId); + + await GitSourceService.getInstance().sweepOrphans(); + + expect(fs.existsSync(candidateAbs)).toBe(true); + }); + + it('preserves a stale, complete candidate directory referenced only by accepted_generation_id', async () => { + const { appId, generationId, candidateAbs } = await stageStaleCandidate('sweep-claims-accepted-only', 'c4'); + // Simulate the sourceAccepted-committed-but-not-yet-promoted window: + // accepted_generation_id names this candidate, the row's own candidate + // pointer is gone, and the pending record no longer references it + // either, so this pointer alone must be what preserves the directory. + DatabaseService.getInstance().getDb() + .prepare('UPDATE gitops_applications SET accepted_generation_id = ? WHERE id = ?') + .run(generationId, appId); + clearCandidatePointer(appId); + clearPendingFetch('sweep-claims-accepted-only'); + + await GitSourceService.getInstance().sweepOrphans(); + + expect(fs.existsSync(candidateAbs)).toBe(true); + }); + + it('preserves a stale candidate referenced only by an unsettled attempt', async () => { + const { appId, generationId, candidateAbs } = await stageStaleCandidate('sweep-claims-unsettled', 'c5'); + const generation = GitOpsStore.getInstance().getGeneration(generationId)!; + clearCandidatePointer(appId); + clearPendingFetch('sweep-claims-unsettled'); + DatabaseService.getInstance().getDb() + .prepare("DELETE FROM gitops_history WHERE application_id = ? AND operation_id = ? AND stage = 'source_reconcile_settled'") + .run(appId, generation.operation_id); + + await GitSourceService.getInstance().sweepOrphans(); + + expect(fs.existsSync(candidateAbs)).toBe(true); + }); + + it('preserves stale candidates when pending claimant metadata is unreadable', async () => { + const stackName = 'sweep-preserves-unreadable-claims'; + const { appId, candidateAbs } = await stageStaleCandidate(stackName, 'c6'); + clearCandidatePointer(appId); + DatabaseService.getInstance().getDb() + .prepare('UPDATE stack_git_sources SET pending_compose_content = ? WHERE stack_name = ?') + .run('{"v":4 invalid', stackName); + + await GitSourceService.getInstance().sweepOrphans(); + + expect(fs.existsSync(candidateAbs)).toBe(true); + expect(DatabaseService.getInstance().getGitSource(stackName)?.manifest_state).toBe('migration_required'); + }); + + it('preserves stale candidates when a generation claimant pointer is dangling', async () => { + const stackName = 'sweep-preserves-dangling-claim'; + const { appId, candidateAbs } = await stageStaleCandidate(stackName, 'c7'); + clearPendingFetch(stackName); + DatabaseService.getInstance().getDb() + .prepare('UPDATE gitops_applications SET candidate_generation_id = ? WHERE id = ?') + .run('missing-generation', appId); + + await GitSourceService.getInstance().sweepOrphans(); + + expect(fs.existsSync(candidateAbs)).toBe(true); + expect(DatabaseService.getInstance().getGitSource(stackName)?.manifest_state).toBe('migration_required'); + }); + + it('preserves stale candidates when unsettled-attempt claimant lookup fails', async () => { + const stackName = 'sweep-preserves-claim-query-failure'; + const { appId, candidateAbs } = await stageStaleCandidate(stackName, 'c8'); + clearCandidatePointer(appId); + clearPendingFetch(stackName); + const claimantSpy = vi.spyOn(GitOpsStore.prototype, 'listGenerationsClaimedByUnsettledAttempts') + .mockImplementationOnce(() => { throw new Error('simulated claimant query failure'); }); + + try { + await GitSourceService.getInstance().sweepOrphans(); + + expect(fs.existsSync(candidateAbs)).toBe(true); + expect(DatabaseService.getInstance().getGitSource(stackName)?.manifest_state).toBe('migration_required'); + } finally { + claimantSpy.mockRestore(); + } + }); +}); diff --git a/backend/src/__tests__/git-support-matrix.test.ts b/backend/src/__tests__/git-support-matrix.test.ts new file mode 100644 index 00000000..672ce75e --- /dev/null +++ b/backend/src/__tests__/git-support-matrix.test.ts @@ -0,0 +1,399 @@ +/** + * Validates the Git transport support matrix (docs/git-transport-support.yaml) + * against the reality it claims to describe, so a published claim can never + * silently outrun its evidence. + * + * The renderer this test imports (backend/scripts/git-support-matrix/*) + * lives outside backend's tsconfig `rootDir` (pinned to `src`), the same + * constraint git-transport-auth.integration.test.ts documents for its own + * cross-directory fixture reuse. A static `import` there would fail + * `tsc --noEmit`; `require()` at runtime does not, since TS never has to + * resolve or type-check a file it was not statically asked to include. + */ +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import ts from 'typescript'; +import { gitSourceStatus } from '../utils/gitSourceHttp'; +import type { GitSourceErrorCode } from '../services/GitSourceService'; +import { resolveTestHandle } from './__helpers__/testHandleResolver'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const matrixRenderer = require('../../scripts/git-support-matrix/render'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { loadClaimSet, REPO_ROOT } = require('../../scripts/git-support-matrix/loadClaimSet'); + +const CLOSED_ENUMS = { + transport: ['https', 'ssh'], + ref: ['branch', 'tag', 'sha'], + auth: ['none', 'pat', 'deploy-key'], + host: ['github', 'gitlab', 'gitea', 'forgejo', 'bitbucket', 'generic'], + ca: ['system', 'per-source', 'not-applicable'], + node_path: ['local', 'direct-proxy', 'pilot'], + port: ['default', 'nonstandard'], + support: ['supported', 'unsupported', 'unverified'], +}; + +const REQUIRED_CLAIM_KEYS = ['id', 'transport', 'ref', 'auth', 'host', 'ca', 'node_path', 'support', 'qualifiers']; +const OPTIONAL_CLAIM_KEYS = ['port', 'evidence']; +const ALLOWED_CLAIM_KEYS = new Set([...REQUIRED_CLAIM_KEYS, ...OPTIONAL_CLAIM_KEYS]); + +interface Claim { + id: string; + transport: string; + ref: string; + auth: string; + host: string; + ca: string; + node_path: string; + port?: string; + support: string; + qualifiers: string[]; + limitations?: string[]; + evidence?: { + kind: 'automated' | 'live'; + outcome: 'success' | 'rejected'; + handles?: { file: string; title: string }[]; + attestation?: string; + }; +} + +interface Attestation { + id: string; + date: string; + source_commit: string; + sencho_image_digest?: string; + host: string; + node_path: string; + transport?: string; + ref?: string; + auth?: string; + ca?: string; + result: 'success' | 'rejected'; +} + +/** Pure schema validation: closed enums, required keys, no unknown fields, resolvable limitation refs. */ +function validateClaimSchema(claim: Record, limitationIds: Set): string[] { + const errors: string[] = []; + const keys = Object.keys(claim); + + for (const key of REQUIRED_CLAIM_KEYS) { + if (!(key in claim)) errors.push(`missing required key "${key}"`); + } + for (const key of keys) { + if (!ALLOWED_CLAIM_KEYS.has(key)) errors.push(`unknown key "${key}"`); + } + for (const [field, allowed] of Object.entries(CLOSED_ENUMS)) { + if (field === 'support' && typeof claim.support === 'string' && !allowed.includes(claim.support)) { + errors.push(`invalid support "${String(claim.support)}"`); + } else if (field in claim && field !== 'support') { + const value = (claim as Record)[field]; + if (typeof value === 'string' && !allowed.includes(value)) { + errors.push(`invalid ${field} "${value}"`); + } + } + } + if (Array.isArray(claim.limitations)) { + for (const id of claim.limitations as string[]) { + if (!limitationIds.has(id)) errors.push(`unknown limitation id "${id}"`); + } + } + return errors; +} + +/** Pure evidence-semantics validation, independent of file/AST resolution. */ +function validateClaimEvidence(claim: Claim): string[] { + const errors: string[] = []; + const { support, evidence } = claim; + + if (support === 'unverified') { + if (evidence) errors.push('unverified claim must not carry evidence'); + return errors; + } + + if (!evidence) { + errors.push(`${support} claim requires evidence`); + return errors; + } + + const expectedOutcome = support === 'supported' ? 'success' : 'rejected'; + if (evidence.outcome !== expectedOutcome) { + errors.push(`${support} claim requires evidence.outcome "${expectedOutcome}", got "${evidence.outcome}"`); + } + + if (evidence.kind === 'automated' && (!evidence.handles || evidence.handles.length === 0)) { + errors.push('automated evidence requires at least one handle'); + } + if (evidence.kind === 'live' && !evidence.attestation) { + errors.push('live evidence requires an attestation id'); + } + return errors; +} + +/** Cross-checks a live claim's dimensions and baseline against its attestation. */ +function validateLiveEvidenceIntegrity(claim: Claim, attestationsById: Map, expectedBaseline: string): string[] { + if (claim.support === 'unverified' || claim.evidence?.kind !== 'live') return []; + const errors: string[] = []; + const attestation = claim.evidence.attestation ? attestationsById.get(claim.evidence.attestation) : undefined; + + if (!attestation) { + errors.push(`claim "${claim.id}" references missing attestation "${String(claim.evidence.attestation)}"`); + return errors; + } + if (attestation.source_commit !== expectedBaseline) { + errors.push(`claim "${claim.id}"'s attestation is stale: source_commit "${attestation.source_commit}" != implementation_baseline "${expectedBaseline}"`); + } + if (attestation.host !== claim.host) errors.push(`claim "${claim.id}" host mismatch with its attestation`); + if (attestation.node_path !== claim.node_path) errors.push(`claim "${claim.id}" node_path mismatch with its attestation`); + if (attestation.transport !== undefined && attestation.transport !== claim.transport) errors.push(`claim "${claim.id}" transport mismatch with its attestation`); + if (attestation.ref !== undefined && attestation.ref !== claim.ref) errors.push(`claim "${claim.id}" ref mismatch with its attestation`); + if (attestation.auth !== undefined && attestation.auth !== claim.auth) errors.push(`claim "${claim.id}" auth mismatch with its attestation`); + if (attestation.ca !== undefined && attestation.ca !== claim.ca) errors.push(`claim "${claim.id}" ca mismatch with its attestation`); + const expectedOutcome = claim.support === 'supported' ? 'success' : 'rejected'; + if (attestation.result !== expectedOutcome) errors.push(`claim "${claim.id}" attestation result "${attestation.result}" contradicts claim support "${claim.support}"`); + return errors; +} + +function extractStringUnionMembers(filePath: string, typeName: string): string[] { + const sourceText = fs.readFileSync(filePath, 'utf8'); + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true); + const members: string[] = []; + const collect = (typeNode: ts.TypeNode): void => { + if (ts.isUnionTypeNode(typeNode)) { + typeNode.types.forEach(collect); + } else if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) { + members.push(typeNode.literal.text); + } + }; + const visit = (node: ts.Node): void => { + if (ts.isTypeAliasDeclaration(node) && node.name.text === typeName) { + collect(node.type); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + if (members.length === 0) throw new Error(`Type alias ${typeName} not found (or has no string-literal members) in ${filePath}`); + return members; +} + +describe('git transport support matrix', () => { + const { support, attestations } = loadClaimSet() as { support: { claims: Claim[]; limitations: { id: string; title: string; statement: string }[]; error_model: { code: string; label: string; status: number; meaning: string }[]; reconciliation_only_codes: string[]; implementation_baseline: string }; attestations: { attestations: Attestation[] } }; + const limitationIds = new Set(support.limitations.map((l) => l.id)); + const attestationsById = new Map(attestations.attestations.map((a) => [a.id, a])); + + describe('schema', () => { + it('every real claim is schema-valid', () => { + for (const claim of support.claims) { + expect(validateClaimSchema(claim as unknown as Record, limitationIds), claim.id).toEqual([]); + } + }); + + it('every claim id is unique', () => { + const ids = support.claims.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('rejects an invalid enum value', () => { + const bad = { ...support.claims[0], transport: 'ftp' }; + expect(validateClaimSchema(bad, limitationIds)).toContain('invalid transport "ftp"'); + }); + + it('rejects an unknown field', () => { + const bad = { ...support.claims[0], bogusField: true }; + expect(validateClaimSchema(bad, limitationIds)).toContain('unknown key "bogusField"'); + }); + + it('rejects a dangling limitation reference', () => { + const bad = { ...support.claims[0], limitations: ['does-not-exist'] }; + expect(validateClaimSchema(bad, limitationIds)).toContain('unknown limitation id "does-not-exist"'); + }); + + it('rejects a claim missing a required key', () => { + const bad = { ...support.claims[0] } as Record; + delete bad.host; + expect(validateClaimSchema(bad, limitationIds)).toContain('missing required key "host"'); + }); + }); + + describe('evidence semantics', () => { + it('every real claim satisfies evidence semantics', () => { + for (const claim of support.claims) { + expect(validateClaimEvidence(claim), claim.id).toEqual([]); + } + }); + + it('rejects a supported claim with no evidence', () => { + const bad: Claim = { ...support.claims.find((c) => c.support === 'supported')!, evidence: undefined }; + expect(validateClaimEvidence(bad)).toContain('supported claim requires evidence'); + }); + + it('rejects an unverified claim that carries evidence', () => { + const template = support.claims.find((c) => c.support === 'supported')!; + const bad: Claim = { ...template, support: 'unverified' }; + expect(validateClaimEvidence(bad)).toContain('unverified claim must not carry evidence'); + }); + + it('rejects a supported claim whose evidence outcome is "rejected"', () => { + const template = support.claims.find((c) => c.support === 'supported' && c.evidence)!; + const bad: Claim = { ...template, evidence: { ...template.evidence!, outcome: 'rejected' } }; + expect(validateClaimEvidence(bad)).toContain('supported claim requires evidence.outcome "success", got "rejected"'); + }); + + it('rejects automated evidence with no handles', () => { + const template = support.claims.find((c) => c.evidence?.kind === 'automated')!; + const bad: Claim = { ...template, evidence: { ...template.evidence!, handles: [] } }; + expect(validateClaimEvidence(bad)).toContain('automated evidence requires at least one handle'); + }); + + it('requires an unsupported claim to carry rejection evidence, not silence', () => { + const template = support.claims.find((c) => c.support === 'supported')!; + const bad: Claim = { ...template, support: 'unsupported', evidence: undefined }; + expect(validateClaimEvidence(bad)).toContain('unsupported claim requires evidence'); + }); + }); + + describe('page binding', () => { + it('the committed MDX is byte-identical to a fresh render of the YAML', () => { + const mdxPath = path.join(REPO_ROOT, 'docs', 'features', 'git-transport-support.mdx'); + expect(matrixRenderer.renderFullMdx()).toBe(fs.readFileSync(mdxPath, 'utf8')); + }); + + it('re-rendering with a mutated claim produces different generated output', () => { + const mutated = { + support: { + ...support, + claims: support.claims.map((c, i) => (i === 0 ? { ...c, support: 'unsupported' } : c)), + }, + attestations, + }; + const original = matrixRenderer.renderGeneratedBlock({ support, attestations }); + const changed = matrixRenderer.renderGeneratedBlock(mutated); + expect(changed).not.toBe(original); + }); + }); + + describe('proof handles', () => { + const automatedClaims = support.claims.filter((c) => c.evidence?.kind === 'automated'); + + it('has at least one automated claim to check', () => { + expect(automatedClaims.length).toBeGreaterThan(0); + }); + + for (const claim of automatedClaims) { + for (const handle of claim.evidence!.handles ?? []) { + it(`resolves "${handle.title}" in ${handle.file} (claim ${claim.id})`, () => { + const absPath = path.join(REPO_ROOT, handle.file); + const sourceText = fs.readFileSync(absPath, 'utf8'); + const result = resolveTestHandle(absPath, sourceText, handle.title); + expect(result, JSON.stringify(result)).toEqual({ ok: true }); + }); + } + } + + // Mutation coverage for the resolver's found/duplicate/skip/ancestor + // logic itself lives in testHandleResolver.test.ts; this suite only + // needs to prove every real handle actually resolves. + }); + + describe('live evidence integrity', () => { + it('every real live claim (if any) is internally consistent', () => { + for (const claim of support.claims) { + expect(validateLiveEvidenceIntegrity(claim, attestationsById, support.implementation_baseline), claim.id).toEqual([]); + } + }); + + it('rejects a live claim referencing a missing attestation', () => { + const bad: Claim = { + id: 'synthetic-missing-attestation', transport: 'https', ref: 'branch', auth: 'pat', + host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [], + evidence: { kind: 'live', outcome: 'success', attestation: 'does-not-exist' }, + }; + expect(validateLiveEvidenceIntegrity(bad, attestationsById, support.implementation_baseline).length).toBeGreaterThan(0); + }); + + it('rejects a live claim whose attestation baseline is stale', () => { + const fakeAttestations = new Map([ + ['att-stale', { id: 'att-stale', date: '2020-01-01', source_commit: 'deadbeef', host: 'github', node_path: 'local', result: 'success' }], + ]); + const bad: Claim = { + id: 'synthetic-stale', transport: 'https', ref: 'branch', auth: 'pat', + host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [], + evidence: { kind: 'live', outcome: 'success', attestation: 'att-stale' }, + }; + const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline); + expect(errors.some((e) => e.includes('stale'))).toBe(true); + }); + + it('rejects a live claim whose node_path does not match its attestation', () => { + const fakeAttestations = new Map([ + ['att-mismatch', { id: 'att-mismatch', date: '2026-01-01', source_commit: support.implementation_baseline, host: 'github', node_path: 'direct-proxy', result: 'success' }], + ]); + const bad: Claim = { + id: 'synthetic-mismatch', transport: 'https', ref: 'branch', auth: 'pat', + host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [], + evidence: { kind: 'live', outcome: 'success', attestation: 'att-mismatch' }, + }; + const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline); + expect(errors.some((e) => e.includes('node_path mismatch'))).toBe(true); + }); + + it('rejects an attestation whose result contradicts the claim support', () => { + const fakeAttestations = new Map([ + ['att-contradict', { id: 'att-contradict', date: '2026-01-01', source_commit: support.implementation_baseline, host: 'github', node_path: 'local', result: 'rejected' }], + ]); + const bad: Claim = { + id: 'synthetic-contradict', transport: 'https', ref: 'branch', auth: 'pat', + host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [], + evidence: { kind: 'live', outcome: 'success', attestation: 'att-contradict' }, + }; + const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline); + expect(errors.some((e) => e.includes('contradicts'))).toBe(true); + }); + }); + + describe('error model partition', () => { + const transportFacingCodes = extractStringUnionMembers( + path.join(REPO_ROOT, 'backend', 'src', 'services', 'git', 'errors.ts'), + 'TransportFacingCode', + ); + const gitSourceErrorCodes = extractStringUnionMembers( + path.join(REPO_ROOT, 'backend', 'src', 'services', 'GitSourceService.ts'), + 'GitSourceErrorCode', + ); + + it('the matrix error_model is exactly TransportFacingCode plus REF_DELETED and FILE_NOT_FOUND', () => { + const expected = new Set([...transportFacingCodes, 'REF_DELETED', 'FILE_NOT_FOUND']); + const actual = new Set(support.error_model.map((e) => e.code)); + expect(actual).toEqual(expected); + }); + + it('reconciliation_only_codes plus the matrix error_model partitions all of GitSourceErrorCode exactly once', () => { + const matrixCodes = support.error_model.map((e) => e.code); + const reconciliationCodes: string[] = support.reconciliation_only_codes; + const combined = [...matrixCodes, ...reconciliationCodes]; + + expect(new Set(combined).size).toBe(combined.length); // no code in both sets + expect(new Set(combined)).toEqual(new Set(gitSourceErrorCodes)); // covers every code + }); + + it('every published status matches the real gitSourceStatus mapping', () => { + for (const entry of support.error_model) { + expect(gitSourceStatus(entry.code as GitSourceErrorCode), entry.code).toBe(entry.status); + } + }); + + it('rejects a matrix that leaves a code unclassified', () => { + const incomplete = support.error_model.filter((e) => e.code !== 'GIT_ERROR').map((e) => e.code); + const reconciliationCodes: string[] = support.reconciliation_only_codes; + const combined = [...incomplete, ...reconciliationCodes]; + expect(new Set(combined)).not.toEqual(new Set(gitSourceErrorCodes)); + }); + }); + + describe('rate limiting', () => { + it('is classified in the error model, not left as a documented limitation', () => { + expect(limitationIds.has('no-rate-limit-classification')).toBe(false); + expect(support.error_model.some((e) => e.code === 'RATE_LIMITED')).toBe(true); + }); + }); +}); diff --git a/backend/src/__tests__/git-transport-auth.integration.test.ts b/backend/src/__tests__/git-transport-auth.integration.test.ts index 9ef5a1f5..07e3eef9 100644 --- a/backend/src/__tests__/git-transport-auth.integration.test.ts +++ b/backend/src/__tests__/git-transport-auth.integration.test.ts @@ -26,10 +26,7 @@ import path from 'path'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { classifyGitFailure, isTransportFailure } from '../services/git/errors'; import { nativeGitTransport, verifyFastForward } from '../services/git/nativeGitTransport'; - -function gitAvailable(): boolean { - return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; -} +import { requireGitBinary } from './__helpers__/externalDeps'; const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures'); const VALID_TOKEN = 'sencho-integration-test-token-do-not-leak'; @@ -174,7 +171,7 @@ function serveAuthedRepo(bareDir: string): Promise<{ url: string; close: () => v }); } -describe.skipIf(!gitAvailable())('authenticated native git transport (real git, real TLS, real auth)', () => { +describe.skipIf(!requireGitBinary())('authenticated native git transport (real git, real TLS, real auth)', () => { let repoUrl: string; let closeServer: () => void; let prevExtraCaCerts: string | undefined; diff --git a/backend/src/__tests__/git-transport-ratelimit.integration.test.ts b/backend/src/__tests__/git-transport-ratelimit.integration.test.ts new file mode 100644 index 00000000..c75a3a3f --- /dev/null +++ b/backend/src/__tests__/git-transport-ratelimit.integration.test.ts @@ -0,0 +1,109 @@ +/** + * Real end-to-end proof that a Git host throttle response classifies as + * RATE_LIMITED, not AUTH_FAILED or GIT_ERROR. + * + * The other classifier fixtures (git-transport.test.ts) construct stderr + * strings by hand; this file proves the string a real git binary actually + * produces, since git does not surface an HTTP response body over the + * smart-HTTP protocol; only the status line reaches stderr. A server that + * intercepts every request before touching a real repository is enough: + * resolveRef fails at ls-remote, before any fetch would need real + * repository content. + * + * Soft-skips when the system git binary is unavailable, mirroring the other + * native-git integration suites (see __helpers__/externalDeps.ts). + */ +import { promises as fs, readFileSync } from 'fs'; +import https from 'https'; +import os from 'os'; +import path from 'path'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { classifyGitFailure, isTransportFailure } from '../services/git/errors'; +import { nativeGitTransport } from '../services/git/nativeGitTransport'; +import { requireGitBinary } from './__helpers__/externalDeps'; + +const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures'); + +/** Throttle wording a real host would put in the response body, which git never shows. */ +const THROTTLE_BODY = 'You have exceeded a secondary rate limit. Please wait a few minutes before you try again.'; + +/** Serve a fixed HTTP status to every request, regardless of path or method. */ +function serveStatus(status: number): Promise<{ url: string; close: () => void }> { + return new Promise((resolve, reject) => { + const server = https.createServer( + { + cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')), + key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')), + }, + (req, res) => { + res.statusCode = status; + res.end(THROTTLE_BODY); + }, + ); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('server did not bind')); + return; + } + resolve({ url: `https://127.0.0.1:${address.port}/repo.git`, close: () => server.close() }); + }); + }); +} + +describe.skipIf(!requireGitBinary())('native git transport rate-limit classification (real git, real TLS)', () => { + let prevExtraCaCerts: string | undefined; + const workspaces: string[] = []; + + beforeAll(() => { + prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS; + process.env.NODE_EXTRA_CA_CERTS = path.join(FIXTURES_DIR, 'git-ca.pem'); + }); + + afterAll(() => { + if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS; + else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts; + }); + + afterEach(async () => { + await Promise.all(workspaces.splice(0).map((w) => fs.rm(w, { recursive: true, force: true }))); + }); + + async function makeWorkspace(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-git-ratelimit-ws-')); + workspaces.push(dir); + return dir; + } + + // Both statuses are served with the same throttle body, which is the + // point of the pair: git shows only the status line, so a host that + // signals a throttle as a bare 403 is indistinguishable from a rejected + // credential and must still classify as AUTH_FAILED. + it.each([ + [429, 'RATE_LIMITED'], + [403, 'AUTH_FAILED'], + ] as const)('classifies a real %d response from the host as %s', async (status, code) => { + const served = await serveStatus(status); + try { + const workspaceRoot = await makeWorkspace(); + const failure = await nativeGitTransport + .resolveRef({ repoUrl: served.url, ref: 'main', token: 'irrelevant-token', timeoutMs: 15_000, workspaceRoot }) + .then(() => null, (e: unknown) => e); + + expect(isTransportFailure(failure)).toBe(true); + if (!isTransportFailure(failure)) throw new Error('unreachable'); + expect(failure.reason).toBe('exit'); + if (failure.reason === 'exit') { + // Pins the real stderr shape the classifier's hand-written + // fixtures (git-transport.test.ts) assume: the status line + // reaches stderr, the served body never does. + expect(failure.stderr).toMatch(new RegExp(`requested url returned error:\\s*${status}\\b`, 'i')); + expect(failure.stderr).not.toMatch(/secondary rate limit/i); + } + expect(classifyGitFailure(failure).code).toBe(code); + } finally { + served.close(); + } + }); +}); diff --git a/backend/src/__tests__/git-transport-ssh.integration.test.ts b/backend/src/__tests__/git-transport-ssh.integration.test.ts index 8e0f0ad9..6042a81c 100644 --- a/backend/src/__tests__/git-transport-ssh.integration.test.ts +++ b/backend/src/__tests__/git-transport-ssh.integration.test.ts @@ -13,14 +13,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { classifyGitFailure, isTransportFailure } from '../services/git/errors'; import { nativeGitTransport } from '../services/git/nativeGitTransport'; import { scanHostKeys } from '../services/git/sshTrust'; - -function gitAvailable(): boolean { - return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; -} - -function sshdAvailable(): boolean { - return spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0; -} +import { requireGitBinary, requireSshd } from './__helpers__/externalDeps'; const FILE_CONTENT = 'hello from the ssh fixture repo\n'; @@ -165,7 +158,7 @@ async function startSshGitServer(bareDir: string, port: number): Promise k.line).join('\n'); @@ -182,7 +175,7 @@ async function startSshGitServer(bareDir: string, port: number): Promise { +describe.skipIf(!requireGitBinary() || !requireSshd())('SSH deploy-key native git transport (real git, real sshd, strict host keys)', () => { let fixture: SshGitFixture; const workspaces: string[] = []; let scratchDirs: string[] = []; @@ -296,7 +289,7 @@ describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key native git }); }); -describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key transport on the default SSH port', () => { +describe.skipIf(!requireGitBinary() || !requireSshd())('SSH deploy-key transport on the default SSH port', () => { let fixture: SshGitFixture; const workspaces: string[] = []; diff --git a/backend/src/__tests__/git-transport.test.ts b/backend/src/__tests__/git-transport.test.ts index d2f31f0e..a3e21835 100644 --- a/backend/src/__tests__/git-transport.test.ts +++ b/backend/src/__tests__/git-transport.test.ts @@ -40,6 +40,8 @@ import { } from '../services/git/credentialHelper'; import * as gitBinary from '../services/git/gitBinary'; import { nativeGitTransport, REF_MAX_LEN, startSizeWatchdog, verifyFastForward } from '../services/git/nativeGitTransport'; +import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets'; +import { GIT_ALLOWED_HOST_ENV_VAR } from '../services/git/credentialHelper'; const GIT_EXEC_PATH_STUB = 'C:/Program Files/Git/mingw64/libexec/git-core'; @@ -186,6 +188,7 @@ describe('classifyGitFailure (native git stderr corpus)', () => { ['size', { transportFailure: true as const, reason: 'size', maxBytes: 5 * 1024 * 1024, host: 'h', hasToken: false }, 'Repository exceeds the maximum clone size of 5 MB.'], ['tip-changed', { transportFailure: true as const, reason: 'tip-changed', host: 'h', hasToken: false }, 'Repository tip changed during fetch; retry the pull.'], ['ref-not-found', { transportFailure: true as const, reason: 'ref-not-found', host: 'h', hasToken: false }, 'The configured branch, tag, or commit was not found in the repository.'], + ['ssh-auth-required', { transportFailure: true as const, reason: 'ssh-auth-required', host: 'h', hasToken: false }, 'SSH repository URLs require a deploy key.'], ['unsupported-ref', { transportFailure: true as const, reason: 'unsupported-ref', host: 'h', hasToken: false }, 'The configured commit is not reachable on this repository host. Use a branch or tag, or a commit the host advertises.'], ['timeout', { transportFailure: true as const, reason: 'timeout', host: 'github.com', hasToken: false }, 'Timed out reaching github.com.'], ] as const)('maps structured reason %s verbatim', (_label, failure, message) => { @@ -216,6 +219,122 @@ describe('classifyGitFailure (native git stderr corpus)', () => { expect(c.code).toBe('UNSUPPORTED_REF'); }); + it.each([ + // The HTTP shapes are verified against a real git binary talking to a + // fixture server (git-transport-ratelimit.integration.test.ts): git + // reports the status line only, never the response body. + ['bare 429 from the host', "fatal: unable to access 'https://h/x.git/': The requested URL returned error: 429"], + ['429 with trailing text', "fatal: unable to access 'https://h/x.git/': The requested URL returned error: 429 Too Many Requests"], + // Text a host sends through the pack stream does reach stderr as a + // remote: line, unlike an HTTP response body. + ['remote sideband rate-limit message', "remote: You have exceeded a secondary rate limit. Please wait a few minutes before you try again.\nfatal: the remote end hung up unexpectedly"], + ['remote sideband abuse-detection message', "remote: You have triggered an abuse detection mechanism.\nfatal: the remote end hung up unexpectedly"], + ])('classifies %s as RATE_LIMITED', (_label, stderr) => { + const c = classifyGitFailure({ + transportFailure: true as const, + reason: 'exit', + stderr, + exitCode: 128, + host: 'github.com', + hasToken: true, + }); + expect(c.code).toBe('RATE_LIMITED'); + expect(c.message).toMatch(/rate limited/i); + }); + + it('classifies an unambiguous rate limit as RATE_LIMITED even without a token', () => { + // Rule 3 (see the module header) takes precedence over rule 2's + // no-token private-repo masking: a throttle leaks nothing about repo + // existence, so it should not be reported as REPO_NOT_FOUND. + const c = classifyGitFailure({ + transportFailure: true as const, + reason: 'exit', + stderr: "remote: You have exceeded a secondary rate limit.\nfatal: the remote end hung up unexpectedly", + exitCode: 128, + host: 'github.com', + hasToken: false, + }); + expect(c.code).toBe('RATE_LIMITED'); + }); + + it('does not send a rate-limited operator to rotate a working credential', () => { + // A sideband throttle message can arrive alongside a 403 fatal line, + // which the auth branch below would otherwise claim. Both the code + // and the message are asserted: reporting RATE_LIMITED while still + // saying "check your token" would leave the operator with the same + // wrong action. + const c = classifyGitFailure({ + transportFailure: true as const, + reason: 'exit', + stderr: "remote: You have exceeded a secondary rate limit.\nfatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 403", + exitCode: 128, + host: 'github.com', + hasToken: true, + }); + expect(c.code).toBe('RATE_LIMITED'); + expect(c.message).not.toMatch(/check your token/i); + }); + + it('leaves a bare 403 with no rate-limit wording as an auth failure', () => { + // Guards the other direction, and pins a real constraint: git does + // not surface an HTTP response body, so a host that signals a + // throttle as a bare 403 is indistinguishable from a rejected + // credential. Widening the rate-limit branch to cover every 403 + // would make a genuinely bad token read as a throttle to wait out. + const c = classifyGitFailure({ + transportFailure: true as const, + reason: 'exit', + stderr: "fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 403", + exitCode: 128, + host: 'github.com', + hasToken: true, + }); + expect(c.code).toBe('AUTH_FAILED'); + }); + + it('does not mistake a repository named "rate-limiter" for a rate-limit signal', () => { + // git's fatal line echoes the full repo URL verbatim, so an + // unscoped rate-limit word match would fire on the path itself. A + // genuinely bad token against a repo whose name happens to contain + // rate-limit wording must still classify as an auth failure. + const c = classifyGitFailure({ + transportFailure: true as const, + reason: 'exit', + stderr: "fatal: unable to access 'https://github.com/acme/rate-limiter.git/': The requested URL returned error: 403", + exitCode: 128, + host: 'github.com', + hasToken: true, + }); + expect(c.code).toBe('AUTH_FAILED'); + }); + + it('does not mistake an upload-pack progress counter for a 429 status', () => { + // Progress lines like "Counting objects: 100% (429/429)" reach + // stderr from the server sideband and can contain the literal digits + // 429 with no connection to an HTTP status at all. + const c = classifyGitFailure({ + transportFailure: true as const, + reason: 'exit', + stderr: "remote: Counting objects: 100% (429/429), done.\nfatal: the remote end hung up unexpectedly", + exitCode: 128, + host: 'github.com', + hasToken: false, + }); + expect(c.code).toBe('NETWORK_TIMEOUT'); + }); + + it('does not mistake an unrelated transient-error sideband for a rate limit', () => { + const c = classifyGitFailure({ + transportFailure: true as const, + reason: 'exit', + stderr: "remote: Internal server error, please retry later\nfatal: the remote end hung up unexpectedly", + exitCode: 128, + host: 'github.com', + hasToken: false, + }); + expect(c.code).toBe('NETWORK_TIMEOUT'); + }); + it('scrubs credentials from the generic fallback tail', () => { const c = classifyGitFailure({ transportFailure: true as const, @@ -410,6 +529,41 @@ describe('transport argv hardening', () => { } }); + it('exports the configured HTTPS host[:port] to the credential helper so a cross-host redirect cannot match', async () => { + scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]); + const root = await makeWorkspace(); + try { + await nativeGitTransport.resolveRef({ + repoUrl: 'https://git.example.com/example/repo.git', + ref: 'main', + token: 'sekrit', + timeoutMs: 5000, + workspaceRoot: root, + }); + const env = spawnEnv(0); + expect(env[GIT_ALLOWED_HOST_ENV_VAR]).toBe('git.example.com'); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('does not export the allowed host when no token is supplied (no credentials to scope)', async () => { + scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]); + const root = await makeWorkspace(); + try { + await nativeGitTransport.resolveRef({ + repoUrl: 'https://git.example.com/example/repo.git', + ref: 'main', + timeoutMs: 5000, + workspaceRoot: root, + }); + const env = spawnEnv(0); + expect(env[GIT_ALLOWED_HOST_ENV_VAR]).toBeUndefined(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + it('combines NODE_EXTRA_CA_CERTS with platform defaults into http.sslCAInfo when set (dev/E2E bridge)', async () => { const caPath = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-git-ca-test-')), 'ca.pem'); await fs.writeFile(caPath, '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----\n'); @@ -482,6 +636,40 @@ describe('transport argv hardening', () => { } }); + it('combines a per-source CA PEM into http.sslCAInfo without NODE_EXTRA_CA_CERTS', async () => { + const prev = process.env.NODE_EXTRA_CA_CERTS; + delete process.env.NODE_EXTRA_CA_CERTS; + try { + scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]); + const root = await makeWorkspace(); + const perSourcePem = '-----BEGIN CERTIFICATE-----\nPER-SOURCE-CA-MARKER\n-----END CERTIFICATE-----\n'; + await nativeGitTransport.resolveRef({ + repoUrl: 'https://github.com/example/repo.git', + ref: 'main', + caBundlePem: perSourcePem, + timeoutMs: 5000, + workspaceRoot: root, + }); + const setArgs = spawnArgs(0); + // Git never follows a redirect itself; an approved destination is + // resolved by the preflight and retried explicitly. + expect(setArgs).toContain('http.followRedirects=false'); + const combined = setArgs.find((a) => a.startsWith('http.sslCAInfo=')); + expect(combined).toBeDefined(); + if (process.platform !== 'win32') { + const combinedBody = await fs.readFile( + (combined as string).slice('http.sslCAInfo='.length).replace(/\//g, path.sep), + 'utf8', + ); + expect(combinedBody).toContain('PER-SOURCE-CA-MARKER'); + } + await fs.rm(root, { recursive: true, force: true }); + } finally { + if (prev === undefined) delete process.env.NODE_EXTRA_CA_CERTS; + else process.env.NODE_EXTRA_CA_CERTS = prev; + } + }); + it.each([ ['plain http', 'http://github.com/example/repo.git'], ['embedded userinfo', 'https://user:pass@github.com/example/repo.git'], @@ -490,6 +678,15 @@ describe('transport argv hardening', () => { expect(mockSpawn).not.toHaveBeenCalled(); }); + it('rejects SSH repository URLs without deploy-key authentication before spawning git', async () => { + await expect(nativeGitTransport.resolveRef({ + repoUrl: 'ssh://git@ssh.example/org/repo.git', + ref: 'main', + workspaceRoot: os.tmpdir(), + })).rejects.toMatchObject({ transportFailure: true as const, reason: 'ssh-auth-required' }); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + it('rejects option-injecting ref names', async () => { await expect(nativeGitTransport.resolveRef({ repoUrl: 'https://github.com/example/repo.git', @@ -729,20 +926,53 @@ describe('resolve/fetch/verify flow', () => { } }); - it('resolves an annotated tag through the peeled ^{} commit', async () => { + it('pins HTTPS to the validated address while resolving an annotated tag', async () => { scriptSpawn([{ stdout: `${SHA_B}\trefs/tags/v1\n${SHA_A}\trefs/tags/v1^{}\n`, }]); const root = await makeWorkspace(); try { await expect(nativeGitTransport.resolveRef({ - repoUrl: 'https://github.com/example/repo.git', + repoUrl: 'https://pinned.example:8443/example/repo.git', ref: 'v1', timeoutMs: 5000, workspaceRoot: root, })).resolves.toMatchObject({ commitSha: SHA_A, kind: 'tag' }); const lsRemoteArgs = mockSpawn.mock.calls[0][1] as string[]; expect(lsRemoteArgs).toContain('refs/tags/v1^{}'); + expect(lsRemoteArgs).toContain('http.followRedirects=false'); + expect(lsRemoteArgs).toContain('http.proxy='); + expect(lsRemoteArgs).toContain('http.curloptResolve=pinned.example:8443:93.184.216.34'); + const env = spawnEnv(0); + expect(env.HTTP_PROXY).toBe(''); + expect(env.HTTPS_PROXY).toBe(''); + expect(env.ALL_PROXY).toBe(''); + expect(env.http_proxy).toBe(''); + expect(env.https_proxy).toBe(''); + expect(env.all_proxy).toBe(''); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('pins SSH to the validated address while retaining host identity and port', async () => { + scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]); + const root = await makeWorkspace(); + try { + await expect(nativeGitTransport.resolveRef({ + repoUrl: 'ssh://git@pinned.example:2222/example/repo.git', + ref: 'main', + sshAuth: { + privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nYWJj\n-----END OPENSSH PRIVATE KEY-----\n', + knownHostsEntry: 'pinned.example ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q=\n', + }, + timeoutMs: 5000, + workspaceRoot: root, + })).resolves.toMatchObject({ commitSha: SHA_A, kind: 'branch' }); + + const sshCommand = spawnEnv(0).GIT_SSH_COMMAND; + expect(sshCommand).toContain('Hostname=93.184.216.34'); + expect(sshCommand).toContain('HostKeyAlias=[pinned.example]:2222'); } finally { await fs.rm(root, { recursive: true, force: true }); } @@ -766,12 +996,12 @@ describe('resolve/fetch/verify flow', () => { it('self-resolves a full SHA without a network round trip', async () => { const root = await makeWorkspace(); try { - await expect(nativeGitTransport.resolveRef({ - repoUrl: 'https://github.com/example/repo.git', + await expect(withLoopbackTargetProtection(() => nativeGitTransport.resolveRef({ + repoUrl: 'https://127.0.0.1/example/repo.git', ref: SHA_A.toUpperCase(), timeoutMs: 5000, workspaceRoot: root, - })).resolves.toMatchObject({ commitSha: SHA_A, kind: 'sha' }); + }))).resolves.toMatchObject({ commitSha: SHA_A, kind: 'sha' }); // The SHA needs no ls-remote: the identity IS the value. expect(mockSpawn).not.toHaveBeenCalled(); } finally { @@ -991,6 +1221,26 @@ describe('clone failure classification and final size gate', () => { } }); + it('rejects unsafe targets during fast-forward verification', async () => { + const root = await makeWorkspace(); + try { + await expect(withLoopbackTargetProtection(() => verifyFastForward({ + repoUrl: 'https://127.0.0.1/repo.git', + ancestorSha: SHA_B, + descendantSha: SHA_A, + timeoutMs: 5000, + workspaceRoot: root, + maxBytes: 100 * 1024 * 1024, + }))).rejects.toMatchObject({ + transportFailure: true as const, + reason: 'unsafe-target', + }); + expect(mockSpawn).not.toHaveBeenCalled(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + it('deepens history exponentially with a bounded number of remote fetches', async () => { scriptSpawn([ { code: 0 }, @@ -1341,8 +1591,7 @@ describe('clone failure classification and final size gate', () => { // simulated confirmation has not arrived yet: settling here // would let a caller start cleaning up the workspace while the // child tree is still alive. - await new Promise((r) => setTimeout(r, 55)); - expect(killInvoked).toBe(true); + await vi.waitFor(() => expect(killInvoked).toBe(true), { timeout: 250 }); expect(settled).toBe(false); await expect(promise).rejects.toMatchObject({ transportFailure: true as const, reason: 'timeout' }); diff --git a/backend/src/__tests__/gitCaBundleSink.test.ts b/backend/src/__tests__/gitCaBundleSink.test.ts new file mode 100644 index 00000000..4ebe25a2 --- /dev/null +++ b/backend/src/__tests__/gitCaBundleSink.test.ts @@ -0,0 +1,116 @@ +/** + * The CA bundle sink is the only place a per-fetch PEM file is written for + * git to read via http.sslCAInfo. These tests pin the invariants CodeQL was + * asked to ignore: every output path is inside the supplied metaDir, the + * written content is concatenated PEM only, and a non-PEM NODE_EXTRA_CA_CERTS + * file is dropped rather than passed through to git. + */ +import { promises as fs, mkdtempSync, statSync, writeFileSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { writeCombinedCaBundle } from '../services/git/gitCaBundleSink'; + +const SAMPLE_CA_A = '-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv\n-----END CERTIFICATE-----\n'; +const SAMPLE_CA_B = '-----BEGIN CERTIFICATE-----\nQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=\n-----END CERTIFICATE-----\n'; +// Controlled system CA fixture with a unique marker for deterministic tests +const SYSTEM_CA_FIXTURE = '-----BEGIN CERTIFICATE-----\nSENCHO_TEST_SYSTEM_CA_MARKER_UNIQUE_12345\n-----END CERTIFICATE-----\n'; + +describe('writeCombinedCaBundle', () => { + let metaDir: string; + let prevExtraCaCerts: string | undefined; + + beforeEach(() => { + metaDir = mkdtempSync(path.join(os.tmpdir(), 'sencho-ca-sink-')); + prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS; + }); + + afterEach(async () => { + if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS; + else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts; + await fs.rm(metaDir, { recursive: true, force: true }); + }); + + it('returns null when no per-source PEM and no NODE_EXTRA_CA_CERTS is set', async () => { + delete process.env.NODE_EXTRA_CA_CERTS; + const result = await writeCombinedCaBundle(metaDir, null); + expect(result).toBeNull(); + }); + + it('writes a file inside the supplied metaDir and returns its absolute path', async () => { + delete process.env.NODE_EXTRA_CA_CERTS; + const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A); + expect(result).not.toBeNull(); + // The returned path is the metaDir/combined-ca.pem, with forward slashes + // for git. The test must compare resolved paths, not string prefixes, + // because the metaDir may itself contain a forward-slash via mkdtemp + // (which it does not on POSIX, but path.resolve normalizes either way). + const expected = path.resolve(metaDir, 'combined-ca.pem'); + expect(result!.path.replace(/\//g, path.sep)).toBe(expected); + expect(result!.path.endsWith('combined-ca.pem')).toBe(true); + const body = await fs.readFile(result!.path, 'utf8'); + expect(body).toContain('BEGIN CERTIFICATE'); + expect(body).toContain('END CERTIFICATE'); + }); + + it('writes the file with mode 0600', async () => { + if (process.platform === 'win32') { + // POSIX-only check; Windows ignores mode bits on fs.writeFile. + return; + } + delete process.env.NODE_EXTRA_CA_CERTS; + const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A); + const stat = statSync(result!.path); + // 0o600 -> owner read+write, no group/other bits. + expect(stat.mode & 0o777).toBe(0o600); + }); + + it('includes system, per-source, and env-var CAs when all are provided', async () => { + const extraPath = path.join(metaDir, '..', 'extra-ca.pem'); + writeFileSync(extraPath, SAMPLE_CA_B); + process.env.NODE_EXTRA_CA_CERTS = extraPath; + // Inject a controlled system CA fixture with a unique marker via the + // optional third parameter. This bypasses the platform read so the test + // is deterministic regardless of CI distro. + const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A, SYSTEM_CA_FIXTURE); + expect(result).not.toBeNull(); + const body = await fs.readFile(result!.path, 'utf8'); + // Verify all three categories exist with unique, identifiable markers: + // (1) Per-source PEM (SAMPLE_CA_A) + expect(body).toContain('MIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv'); + // (2) NODE_EXTRA_CA_CERTS file content (SAMPLE_CA_B) + expect(body).toContain('QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='); + // (3) System CA fixture (controlled, unique marker) + expect(body).toContain('SENCHO_TEST_SYSTEM_CA_MARKER_UNIQUE_12345'); + }); + + it('omits system CA when injection parameter is null (negative control)', async () => { + const extraPath = path.join(metaDir, '..', 'extra-ca.pem'); + writeFileSync(extraPath, SAMPLE_CA_B); + process.env.NODE_EXTRA_CA_CERTS = extraPath; + // Pass null to explicitly skip system CA injection + const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A, null); + expect(result).not.toBeNull(); + const body = await fs.readFile(result!.path, 'utf8'); + // Per-source and env-var CAs are present + expect(body).toContain('MIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv'); + expect(body).toContain('QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='); + // System CA marker is NOT present - proves injection was needed + expect(body).not.toContain('SENCHO_TEST_SYSTEM_CA_MARKER_UNIQUE_12345'); + }); + + it('drops a NODE_EXTRA_CA_CERTS file that is not valid PEM rather than writing it through', async () => { + const extraPath = path.join(metaDir, '..', 'extra-ca.pem'); + writeFileSync(extraPath, 'this is not a certificate'); + process.env.NODE_EXTRA_CA_CERTS = extraPath; + // No per-source PEM either: nothing valid to write, so null. + const result = await writeCombinedCaBundle(metaDir, null); + expect(result).toBeNull(); + }); + + it('drops a non-PEM per-source input rather than writing it through', async () => { + delete process.env.NODE_EXTRA_CA_CERTS; + const result = await writeCombinedCaBundle(metaDir, 'not a cert at all'); + expect(result).toBeNull(); + }); +}); diff --git a/backend/src/__tests__/gitops-approvals.test.ts b/backend/src/__tests__/gitops-approvals.test.ts index 2fc591f3..1ab89bab 100644 --- a/backend/src/__tests__/gitops-approvals.test.ts +++ b/backend/src/__tests__/gitops-approvals.test.ts @@ -308,6 +308,11 @@ function directApp(id: string, stackName: string): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, @@ -350,6 +355,12 @@ function generation(id: string, applicationId: string): GitOpsGenerationRow { actor: 'tester', previous_generation_id: null, redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, created_at: 1, }; } diff --git a/backend/src/__tests__/gitops-backoff.test.ts b/backend/src/__tests__/gitops-backoff.test.ts new file mode 100644 index 00000000..79a84f82 --- /dev/null +++ b/backend/src/__tests__/gitops-backoff.test.ts @@ -0,0 +1,157 @@ +/** + * Failure classification and retry-delay coverage for the GitOps source + * controller. classifyFailure is a compile-enforced total map: adding a new + * TransportFailureReason or GitSourceErrorCode without updating the lookup + * tables here fails the build, not just these tests. + */ +import { describe, it, expect } from 'vitest'; +import { + classifyFailure, + nextRetryAt, + DEFAULT_TRANSIENT_CEILING, + LOW_TRANSIENT_CEILING, + type FailureEvidence, +} from '../services/gitops/backoff'; + +function gitSourceError(code: string, transportReason?: string): FailureEvidence { + return { kind: 'git_source_error', code: code as never, transportReason: transportReason as never }; +} + +describe('classifyFailure', () => { + it('classifies a tip-changed race as supersession, not backoff', () => { + expect(classifyFailure(gitSourceError('GIT_ERROR', 'tip-changed'))).toEqual({ class: 'supersession' }); + }); + + it('classifies a standalone timeout reason as transient', () => { + expect(classifyFailure(gitSourceError('NETWORK_TIMEOUT', 'timeout'))) + .toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING }); + }); + + it('classifies DNS resolution failure (target-unresolved) as transient', () => { + expect(classifyFailure(gitSourceError('NETWORK_TIMEOUT', 'target-unresolved'))) + .toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING }); + }); + + it('classifies an exit-coded network timeout as transient', () => { + expect(classifyFailure(gitSourceError('NETWORK_TIMEOUT', 'exit'))) + .toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING }); + }); + + it('classifies an exit-coded rate limit as transient', () => { + expect(classifyFailure(gitSourceError('RATE_LIMITED', 'exit'))) + .toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING }); + }); + + it('classifies an exit-coded unrecognized git error with a low retry ceiling', () => { + expect(classifyFailure(gitSourceError('GIT_ERROR', 'exit'))) + .toEqual({ class: 'transient', retryCeiling: LOW_TRANSIENT_CEILING }); + }); + + it.each(['invalid-url', 'unsafe-target', 'invalid-ref', 'redirect-scope'])( + 'classifies %s as permanent configuration', + (reason) => { + expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' }); + }, + ); + + it.each(['git-missing', 'git-old'])('classifies %s as permanent environment', (reason) => { + expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' }); + }); + + it('classifies a repository over the size cap as permanent', () => { + expect(classifyFailure(gitSourceError('GIT_ERROR', 'size'))).toEqual({ class: 'permanent' }); + }); + + it.each(['ssh-auth-required'])('classifies %s as permanent authorization', (reason) => { + expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' }); + }); + + it.each(['AUTH_FAILED', 'SSH_HOST_KEY_FAILED'])('classifies %s (no transport reason) as permanent', (code) => { + expect(classifyFailure(gitSourceError(code))).toEqual({ class: 'permanent' }); + }); + + it.each(['ref-not-found', 'unsupported-ref'])('classifies %s as permanent configuration', (reason) => { + expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' }); + }); + + it.each(['REPO_NOT_FOUND', 'REF_NOT_FOUND', 'REF_DELETED', 'UNSUPPORTED_REF'])( + 'classifies %s (no transport reason) as permanent', + (code) => { + expect(classifyFailure(gitSourceError(code))).toEqual({ class: 'permanent' }); + }, + ); + + it.each(['STALE_PLAN', 'PLAN_BLOCKED', 'PLAN_FINGERPRINT_REQUIRED', 'LEGACY_PENDING', 'PLAN_UNAVAILABLE', 'FILE_NOT_FOUND'])( + 'classifies %s as requiring operator action', + (code) => { + expect(classifyFailure(gitSourceError(code))).toEqual({ class: 'operator_action_required' }); + }, + ); + + it('classifies a conflicting in-flight operation for reconciliation, not blind retry', () => { + expect(classifyFailure(gitSourceError('OPERATION_IN_FLIGHT'))).toEqual({ class: 'reconcile' }); + }); + + it('classifies an unavailable policy scanner as degraded', () => { + expect(classifyFailure({ kind: 'policy_unavailable' })).toEqual({ class: 'degraded' }); + }); + + it('classifies unavailable persistence as transient with no source-stage progress', () => { + expect(classifyFailure({ kind: 'persistence_unavailable' })) + .toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING }); + }); + + it('classifies an invalid target binding as permanent at the target level', () => { + expect(classifyFailure({ kind: 'target_binding_invalid' })).toEqual({ class: 'target_permanent' }); + }); + + it('classifies a temporarily unavailable target as transient at the target level', () => { + expect(classifyFailure({ kind: 'target_unavailable' })).toEqual({ class: 'target_transient' }); + }); + + it('classifies a deploy/health failure after a successful apply as its own class, never refetch or reapply', () => { + expect(classifyFailure({ kind: 'target_mutation_failed' })).toEqual({ class: 'target_mutation_failed' }); + }); + + it('classifies unavailable Blueprint evaluation as blocked, not retried', () => { + expect(classifyFailure({ kind: 'blueprint_unavailable' })).toEqual({ class: 'blocked' }); + }); + + it('classifies an interrupted or unknown-completion operation for reconciliation', () => { + expect(classifyFailure({ kind: 'interrupted' })).toEqual({ class: 'reconcile' }); + }); +}); + +describe('nextRetryAt', () => { + it('computes the base delay with up to +-10% jitter on the first attempt', () => { + const now = 1_000_000; + const at = nextRetryAt(now, 0); + expect(at).toBeGreaterThanOrEqual(now + 54_000); + expect(at).toBeLessThanOrEqual(now + 66_000); + }); + + it('doubles the delay per retry count', () => { + const now = 1_000_000; + const at = nextRetryAt(now, 3); // 60s * 2^3 = 480s + expect(at).toBeGreaterThanOrEqual(now + 432_000); + expect(at).toBeLessThanOrEqual(now + 528_000); + }); + + it('caps the delay at one hour regardless of retry count', () => { + const now = 1_000_000; + const at = nextRetryAt(now, 20); + expect(at).toBeLessThanOrEqual(now + 3_600_000 * 1.1); + }); + + it('honors a provider retry floor larger than the computed delay', () => { + const now = 1_000_000; + const at = nextRetryAt(now, 0, 10_000_000); + expect(at).toBe(now + 10_000_000); + }); + + it('ignores a provider retry floor smaller than the computed delay', () => { + const now = 1_000_000; + const at = nextRetryAt(now, 5, 1_000); + expect(at).toBeGreaterThan(now + 1_000); + }); +}); diff --git a/backend/src/__tests__/gitops-blueprint-transitions.test.ts b/backend/src/__tests__/gitops-blueprint-transitions.test.ts index 81e3f6c1..ff13ab72 100644 --- a/backend/src/__tests__/gitops-blueprint-transitions.test.ts +++ b/backend/src/__tests__/gitops-blueprint-transitions.test.ts @@ -608,6 +608,11 @@ function inlineApp(id: string, blueprintId: number): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, diff --git a/backend/src/__tests__/gitops-create-recovery.test.ts b/backend/src/__tests__/gitops-create-recovery.test.ts index 07199d4e..71dde879 100644 --- a/backend/src/__tests__/gitops-create-recovery.test.ts +++ b/backend/src/__tests__/gitops-create-recovery.test.ts @@ -184,6 +184,7 @@ describe('gitops interrupted create recovery', () => { encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: SHA, @@ -379,6 +380,7 @@ function seedCreate( encrypted_deploy_key: options.encryptedDeployKey ?? null, ssh_known_hosts_entry: options.sshKnownHostsEntry ?? null, ssh_host_key_fingerprint: options.sshHostKeyFingerprint ?? null, + encrypted_ca_bundle: null, auto_apply_on_webhook: 0, auto_deploy_on_apply: 0, commit_sha: SHA, @@ -438,6 +440,11 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, @@ -480,6 +487,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow { actor: 'tester', previous_generation_id: null, redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, created_at: 1, }; } diff --git a/backend/src/__tests__/gitops-create.test.ts b/backend/src/__tests__/gitops-create.test.ts index 9214f212..1eb9e824 100644 --- a/backend/src/__tests__/gitops-create.test.ts +++ b/backend/src/__tests__/gitops-create.test.ts @@ -655,6 +655,7 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: 0, auto_deploy_on_apply: 0, commit_sha: SHA, @@ -705,6 +706,11 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, @@ -747,6 +753,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow { actor: 'tester', previous_generation_id: null, redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, created_at: 1, }; } diff --git a/backend/src/__tests__/gitops-deferred.test.ts b/backend/src/__tests__/gitops-deferred.test.ts index fc237647..565712da 100644 --- a/backend/src/__tests__/gitops-deferred.test.ts +++ b/backend/src/__tests__/gitops-deferred.test.ts @@ -64,7 +64,11 @@ describe('gitops deferred state', () => { const app = store.getApplication('app-susp')!; expect(app.suspended_at).not.toBeNull(); expect(app.accepted_generation_id).toBe(accepted); - expect(projectOf('app-susp').facets.source.status).toBe('source_suspended'); + const sourceFacet = projectOf('app-susp').facets.source; + expect(sourceFacet.status).toBe('source_suspended'); + if (sourceFacet.status === 'source_suspended') { + expect(sourceFacet.suspendedReason).toBe('operator paused sync'); + } // A suspended source refuses new work rather than queueing it. expect(() => tx.fetchStarted('app-susp', env('op-susp-f'))).toThrow(/suspended/); @@ -89,6 +93,27 @@ describe('gitops deferred state', () => { expect(app.suspended_at).not.toBeNull(); }); + it('keeps a source-suspension reason independent of an application-wide rollout pause reason', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + seedApplied('app-susp3', 'susp3-web'); + + tx.sourceSuspended('app-susp3', 'operator paused sync', env('op-susp3')); + // A later, unrelated application-wide rollout pause must not clobber the + // suspension reason: the two events share the application row but not + // its reason field. + tx.rolloutPaused('app-susp3', null, 'awaiting approval', env('op-pause3')); + + const app = store.getApplication('app-susp3')!; + expect(app.source_suspended_reason).toBe('operator paused sync'); + expect(app.pause_reason).toBe('awaiting approval'); + + tx.sourceUnsuspended('app-susp3', env('op-unsusp3')); + expect(store.getApplication('app-susp3')?.source_suspended_reason).toBeNull(); + // Unsuspending the source must not touch the unrelated rollout pause. + expect(store.getApplication('app-susp3')?.pause_reason).toBe('awaiting approval'); + }); + it('pauses a rollout without claiming anything about health', () => { const store = GitOpsStore.getInstance(); const tx = GitOpsTransitions.getInstance(); @@ -314,6 +339,11 @@ function app(id: string, stackName: string): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, @@ -356,6 +386,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow { actor: 'tester', previous_generation_id: null, redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, created_at: 1, }; } diff --git a/backend/src/__tests__/gitops-derive.test.ts b/backend/src/__tests__/gitops-derive.test.ts index 3aa9496a..dd0f18b6 100644 --- a/backend/src/__tests__/gitops-derive.test.ts +++ b/backend/src/__tests__/gitops-derive.test.ts @@ -962,6 +962,11 @@ function app(id: string, stackName: string): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, @@ -1009,6 +1014,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow { actor: 'tester', previous_generation_id: null, redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, created_at: 1, }; } diff --git a/backend/src/__tests__/gitops-direct-producers.test.ts b/backend/src/__tests__/gitops-direct-producers.test.ts index 13b3191a..936a30a1 100644 --- a/backend/src/__tests__/gitops-direct-producers.test.ts +++ b/backend/src/__tests__/gitops-direct-producers.test.ts @@ -644,7 +644,7 @@ describe('Direct Git producers drive the revision state', () => { expect(recovered.active_operation_stage).toBeNull(); }); - it('leaves a stack with no GitOps application untouched', async () => { + it('refuses to fetch when a configured stack has no GitOps application', async () => { const svc = GitSourceService.getInstance(); const store = GitOpsStore.getInstance(); const stackName = 'producers-legacy'; @@ -667,6 +667,7 @@ describe('Direct Git producers drive the revision state', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: 'eeeeeee5', @@ -680,9 +681,12 @@ describe('Direct Git producers drive the revision state', () => { expect(store.getLiveDirectApplication(stackName)).toBeUndefined(); stageRepo(COMPOSE_V2, 'fffffff6'); - await svc.pull(stackName, { actor: 'tester' }); + await expect(svc.pull(stackName, { actor: 'tester' })).rejects.toMatchObject({ + code: 'GIT_ERROR', + message: expect.stringContaining('GitOps tracking is unavailable'), + }); - // The pull succeeded operationally and wrote no GitOps rows. + // No untracked fetch or GitOps history was written. expect(store.getLiveDirectApplication(stackName)).toBeUndefined(); const historyRows = (await import('../services/DatabaseService')).DatabaseService .getInstance().getDb() diff --git a/backend/src/__tests__/gitops-handoff.test.ts b/backend/src/__tests__/gitops-handoff.test.ts new file mode 100644 index 00000000..7956af58 --- /dev/null +++ b/backend/src/__tests__/gitops-handoff.test.ts @@ -0,0 +1,115 @@ +/** + * The accepted-generation contract: a portable, content-only projection of + * a gitops_generations row, plus the target-dispatch boundary. + */ +import { describe, it, expect } from 'vitest'; +import { + buildAcceptedGeneration, + BlueprintTargetAdapter, + type AcceptedGeneration, +} from '../services/gitops/handoff'; +import type { GitOpsGenerationRow } from '../services/gitops/types'; + +function baseRow(overrides: Partial = {}): GitOpsGenerationRow { + return { + id: 'gen-1', + application_id: 'app-1', + commit_sha: 'a'.repeat(40), + repo_url: 'https://github.com/example/repo.git', + configured_ref: 'main', + resolved_ref_kind: 'branch', + repo_identity_json: '{"host":"github.com","pathname":"/example/repo.git"}', + manifest_version: 4, + candidate_dir: 'generations/candidate-a', + applied_dir: 'generations/applied-a-0', + expected_invocation_json: '{}', + materialization_fingerprint: 'f'.repeat(64), + validation_ok: 1, + plan_blocked: 0, + change_plan_fingerprint: 'fp-1', + operation_id: 'op-1', + trigger: 'manual', + actor: 'tester', + previous_generation_id: null, + redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, + created_at: 1, + ...overrides, + }; +} + +describe('buildAcceptedGeneration', () => { + it('decodes identity and lineage fields directly from the row', () => { + const gen = buildAcceptedGeneration(baseRow()); + expect(gen.contractVersion).toBe(1); + expect(gen.generationId).toBe('gen-1'); + expect(gen.applicationId).toBe('app-1'); + expect(gen.repoIdentity).toEqual({ host: 'github.com', pathname: '/example/repo.git' }); + expect(gen.configuredRef).toBe('main'); + expect(gen.commitSha).toBe('a'.repeat(40)); + expect(gen.resolvedRefKind).toBe('branch'); + expect(gen.validationOk).toBe(true); + expect(gen.trigger).toBe('manual'); + expect(gen.operationId).toBe('op-1'); + }); + + it('records an explicit limitation for each missing portable field on a legacy row, never inventing evidence', () => { + const gen = buildAcceptedGeneration(baseRow()); + expect(gen.portableManifest).toBeNull(); + expect(gen.composeInputs).toBeNull(); + expect(gen.sourcePolicyEvidence).toBeNull(); + expect(gen.limitations).toEqual(expect.arrayContaining([ + 'portable_manifest_missing', + 'compose_inputs_missing', + 'source_policy_evidence_missing', + 'security_policy_evidence_missing', + 'support_requirements_missing', + 'compatibility_requirements_missing', + ])); + }); + + it('decodes real evidence when the row carries it, recording no limitation for that field', () => { + const gen = buildAcceptedGeneration(baseRow({ + portable_manifest_json: '{"files":[]}', + compose_inputs_json: '{"composeFileOrder":["compose.yaml"]}', + })); + expect(gen.portableManifest).toEqual({ files: [] }); + expect(gen.composeInputs).toEqual({ composeFileOrder: ['compose.yaml'] }); + expect(gen.limitations).not.toContain('portable_manifest_missing'); + expect(gen.limitations).not.toContain('compose_inputs_missing'); + }); + + it('refuses to build a contract from an unparseable repo identity', () => { + expect(() => buildAcceptedGeneration(baseRow({ repo_identity_json: 'not json' }))).toThrow(); + }); + + it('never populates secretCapability with a value, only its absence as capability metadata', () => { + const gen = buildAcceptedGeneration(baseRow()); + expect(gen.secretCapability).toBeNull(); + }); +}); + +// A future field on AcceptedGeneration named like a target-mode concept +// (selector, frozen target set, node id, rollout batch, target project +// name, local path, or secret value) must fail this compile, not merely +// this test run: the contract stays structurally content-only. +type AssertNoTargetModeFields = T extends Record< + 'selector' | 'targetSet' | 'nodeId' | 'nodeIds' | 'rolloutBatch' | 'projectName' | 'candidateDir' | 'secretValue', + unknown +> ? never : true; +const _structurallyContentOnly: AssertNoTargetModeFields = true; +void _structurallyContentOnly; + +describe('BlueprintTargetAdapter', () => { + it('always returns a durable blocked result, never inspecting selectors or placement', async () => { + const adapter = new BlueprintTargetAdapter(); + const gen = buildAcceptedGeneration(baseRow()); + const result = await adapter.dispatch(gen, { targetMode: 'blueprint', nodeId: null, bindingRevision: null }); + expect(result.status).toBe('blocked'); + }); +}); diff --git a/backend/src/__tests__/gitops-history-read.test.ts b/backend/src/__tests__/gitops-history-read.test.ts index c6520550..d1f10aef 100644 --- a/backend/src/__tests__/gitops-history-read.test.ts +++ b/backend/src/__tests__/gitops-history-read.test.ts @@ -530,6 +530,11 @@ function application(): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, diff --git a/backend/src/__tests__/gitops-managed-sweep.test.ts b/backend/src/__tests__/gitops-managed-sweep.test.ts index 93b839d9..32f104ed 100644 --- a/backend/src/__tests__/gitops-managed-sweep.test.ts +++ b/backend/src/__tests__/gitops-managed-sweep.test.ts @@ -115,6 +115,7 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: 0, auto_deploy_on_apply: 0, commit_sha: SHA, @@ -165,6 +166,11 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, diff --git a/backend/src/__tests__/gitops-migrate.test.ts b/backend/src/__tests__/gitops-migrate.test.ts index 212bea3d..ed089ccf 100644 --- a/backend/src/__tests__/gitops-migrate.test.ts +++ b/backend/src/__tests__/gitops-migrate.test.ts @@ -358,6 +358,7 @@ function seedStack( env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: options.lastApplied, diff --git a/backend/src/__tests__/gitops-outcomes.test.ts b/backend/src/__tests__/gitops-outcomes.test.ts new file mode 100644 index 00000000..48eb975b --- /dev/null +++ b/backend/src/__tests__/gitops-outcomes.test.ts @@ -0,0 +1,152 @@ +/** + * Normalized reconcile-outcome coverage. outcomeFromSourceFacet derives from + * the existing SourceFacet projection rather than inventing a second status + * source, so "no source change" and "converged" cannot silently collapse + * into the same result. + */ +import { describe, it, expect } from 'vitest'; +import { outcomeFromSourceFacet } from '../services/gitops/outcomes'; +import type { SourceFacet } from '../services/gitops/types'; + +const identity = { + configuredRepoUrl: 'https://github.com/example/repo.git', + repoIdentity: { host: 'github.com', pathname: '/example/repo.git' }, + configuredRef: 'main', + desiredCommitSha: 'a'.repeat(40), + fetchedCommitSha: 'a'.repeat(40), + candidateGenerationId: null, + acceptedGenerationId: 'gen-1', +}; + +describe('outcomeFromSourceFacet', () => { + it('reports no_source_change for an accepted generation, never converged on SHA alone', () => { + const facet: SourceFacet = { ...identity, status: 'application_generation_accepted' }; + const result = outcomeFromSourceFacet(facet); + expect(result.outcome).toBe('no_source_change'); + expect(result.commitSha).toBe('a'.repeat(40)); + }); + + it('reports candidate_already_fetched for a ready candidate', () => { + const facet: SourceFacet = { ...identity, status: 'candidate_ready' }; + expect(outcomeFromSourceFacet(facet).outcome).toBe('candidate_already_fetched'); + }); + + it('reports pending_review with a review next action', () => { + const facet: SourceFacet = { ...identity, status: 'source_review_pending' }; + const result = outcomeFromSourceFacet(facet); + expect(result.outcome).toBe('pending_review'); + expect(result.nextAction).toBe('review'); + }); + + it('reports blocked with a resolve_conflict next action for a source conflict', () => { + const facet: SourceFacet = { ...identity, status: 'source_conflict_blocker' }; + const result = outcomeFromSourceFacet(facet); + expect(result.outcome).toBe('blocked'); + expect(result.nextAction).toBe('resolve_conflict'); + }); + + it('reports superseded for a candidate a newer revision replaced', () => { + const facet: SourceFacet = { ...identity, status: 'source_superseded', supersededGenerationId: 'gen-old' }; + expect(outcomeFromSourceFacet(facet).outcome).toBe('superseded'); + }); + + it('reports retry_scheduled with the retry time surfaced', () => { + const facet: SourceFacet = { ...identity, status: 'source_retry_scheduled', retryAt: 12345, retryCount: 2 }; + const result = outcomeFromSourceFacet(facet); + expect(result.outcome).toBe('retry_scheduled'); + expect(result.retryAt).toBe(12345); + expect(result.nextAction).toBe('none'); + }); + + it('reports suspended with a resume next action', () => { + const facet: SourceFacet = { ...identity, status: 'source_suspended', suspendedAt: 999, suspendedReason: 'operator paused sync' }; + const result = outcomeFromSourceFacet(facet); + expect(result.outcome).toBe('suspended'); + expect(result.nextAction).toBe('resume'); + expect(result.reason).toContain('operator paused sync'); + }); + + it('reports failed_previous_intact for a source failure, with a retry next action when a retry is scheduled', () => { + const facet: SourceFacet = { + ...identity, + status: 'source_failed', + failureStage: 'fetch', + failureClass: 'permanent', + failureAt: 100, + retryAt: 200, + retryCount: 1, + }; + const result = outcomeFromSourceFacet(facet); + expect(result.outcome).toBe('failed_previous_intact'); + expect(result.nextAction).toBe('retry'); + expect(result.retryAt).toBe(200); + }); + + it('reports failed_previous_intact with no retry next action when no retry is scheduled', () => { + const facet: SourceFacet = { + ...identity, + status: 'source_failed', + failureStage: 'fetch', + failureClass: 'permanent', + failureAt: 100, + retryAt: null, + retryCount: 0, + }; + const result = outcomeFromSourceFacet(facet); + expect(result.outcome).toBe('failed_previous_intact'); + expect(result.nextAction).toBe('configure_credentials'); + }); + + it('reports recovery_required for an interrupted operation', () => { + const facet: SourceFacet = { + ...identity, + status: 'source_unknown', + interruptedStage: 'fetch_started', + interruptedAt: 100, + interruptedOperationId: 'op-1', + interruptedGenerationId: null, + }; + expect(outcomeFromSourceFacet(facet).outcome).toBe('recovery_required'); + }); + + it('reports recovery_required with a view_target_results next action when recovery is outstanding', () => { + const facet: SourceFacet = { ...identity, status: 'recovery_required', recoveryRef: 'rec-1', recoveryGenerationId: 'gen-1' }; + const result = outcomeFromSourceFacet(facet); + expect(result.outcome).toBe('recovery_required'); + expect(result.nextAction).toBe('view_target_results'); + }); + + it('reports recovery_required when recovery itself failed, distinguishing that in the reason', () => { + const facet: SourceFacet = { + ...identity, + status: 'recovery_failed', + recoveryRef: 'rec-1', + recoveryGenerationId: 'gen-1', + failureClass: 'io_error', + failureAt: 100, + }; + const result = outcomeFromSourceFacet(facet); + expect(result.outcome).toBe('recovery_required'); + expect(result.reason).toMatch(/recovery/i); + }); + + it('reports unknown for an application that is no longer live', () => { + const facet: SourceFacet = { ...identity, status: 'not_live', lifecycleStatus: 'detached' }; + expect(outcomeFromSourceFacet(facet).outcome).toBe('unknown'); + }); + + it.each(['not_applicable', 'never_reconciled', 'checking_fetching', 'source_reconcile_required'] as const)( + 'reports unknown for %s, which has no settled outcome yet', + (status) => { + const facet = status === 'not_applicable' + ? ({ status } as SourceFacet) + : ({ ...identity, status } as SourceFacet); + expect(outcomeFromSourceFacet(facet).outcome).toBe('unknown'); + }, + ); + + it('reports unknown while an operation is in flight (applying)', () => { + const facet: SourceFacet = { ...identity, status: 'applying', activeOperationId: 'op-1', activeGenerationId: 'gen-1' }; + expect(outcomeFromSourceFacet(facet).outcome).toBe('unknown'); + }); +}); diff --git a/backend/src/__tests__/gitops-reconcile-attempts.test.ts b/backend/src/__tests__/gitops-reconcile-attempts.test.ts new file mode 100644 index 00000000..9f1699ed --- /dev/null +++ b/backend/src/__tests__/gitops-reconcile-attempts.test.ts @@ -0,0 +1,387 @@ +/** + * Durable reconcile-attempt reservation and settlement: a bare history + * insert in its own transaction, never through mutateApp, so a reservation + * writes no application-row state and can be safely repeated. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { GitOpsStore } from '../services/gitops/store'; +import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions'; +import { DatabaseService } from '../services/DatabaseService'; +import type { GitOpsApplicationRow } from '../services/gitops/types'; + +let tmpDir: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + GitOpsStore.resetForTests(); + GitOpsTransitions.resetForTests(); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +describe('reconcile attempt reservation and settlement', () => { + it('reserves an attempt without touching application state', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-res', 'res-web'), nodeId: 1, envelope: env('op-act-res') }); + const before = store.getApplication('app-res')!; + + const result = tx.reserveReconcileAttempt('app-res', env('op-res-1')); + + expect(result.reserved).toBe(true); + const after = store.getApplication('app-res')!; + expect(after.updated_at).toBe(before.updated_at); + expect(after.desired_commit_sha).toBe(before.desired_commit_sha); + }); + + it('returns reserved: false on a repeated reservation for the same operation', () => { + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-res2', 'res2-web'), nodeId: 1, envelope: env('op-act-res2') }); + + const first = tx.reserveReconcileAttempt('app-res2', env('op-res2-1')); + const second = tx.reserveReconcileAttempt('app-res2', env('op-res2-1')); + + expect(first.reserved).toBe(true); + expect(second.reserved).toBe(false); + }); + + it('allows two different operations to each reserve their own attempt', () => { + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-res3', 'res3-web'), nodeId: 1, envelope: env('op-act-res3') }); + + const first = tx.reserveReconcileAttempt('app-res3', env('op-res3-a')); + const second = tx.reserveReconcileAttempt('app-res3', env('op-res3-b')); + + expect(first.reserved).toBe(true); + expect(second.reserved).toBe(true); + }); + + it('settles a reserved attempt and finds it by operation id afterward', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-settle', 'settle-web'), nodeId: 1, envelope: env('op-act-settle') }); + tx.reserveReconcileAttempt('app-settle', env('op-settle-1')); + + const settleResult = tx.settleReconcileAttempt('app-settle', env('op-settle-1'), { + outcome: 'no_source_change', + reason: 'Nothing new to fetch.', + nextAction: 'none', + }); + + expect(settleResult.settled).toBe(true); + const settled = store.getSettledAttempt('app-settle', 'op-settle-1'); + expect(settled).toBeDefined(); + }); + + it('settling twice for the same operation is a no-op the second time', () => { + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-settle2', 'settle2-web'), nodeId: 1, envelope: env('op-act-settle2') }); + tx.reserveReconcileAttempt('app-settle2', env('op-settle2-1')); + + const first = tx.settleReconcileAttempt('app-settle2', env('op-settle2-1'), { + outcome: 'no_source_change', + reason: 'first', + nextAction: 'none', + }); + const second = tx.settleReconcileAttempt('app-settle2', env('op-settle2-1'), { + outcome: 'no_source_change', + reason: 'second, must not overwrite', + nextAction: 'none', + }); + + expect(first.settled).toBe(true); + expect(second.settled).toBe(false); + }); + + it('has no settled attempt for a reservation that was never settled', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-orphan', 'orphan-web'), nodeId: 1, envelope: env('op-act-orphan') }); + tx.reserveReconcileAttempt('app-orphan', env('op-orphan-1')); + + expect(store.getSettledAttempt('app-orphan', 'op-orphan-1')).toBeUndefined(); + }); + + it('lists an unsettled reservation but not one that has settled', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-unsettled', 'unsettled-web'), nodeId: 1, envelope: env('op-act-unsettled') }); + tx.reserveReconcileAttempt('app-unsettled', env('op-unsettled-orphan')); + tx.reserveReconcileAttempt('app-unsettled', env('op-unsettled-done')); + tx.settleReconcileAttempt('app-unsettled', env('op-unsettled-done'), { + outcome: 'no_source_change', + reason: 'done', + nextAction: 'none', + }); + + const unsettled = store.listUnsettledReconcileAttempts(); + const operationIds = unsettled.filter((r) => r.application_id === 'app-unsettled').map((r) => r.operation_id); + expect(operationIds).toContain('op-unsettled-orphan'); + expect(operationIds).not.toContain('op-unsettled-done'); + }); + + it('gets the started row for one exact attempt, or undefined when it was never reserved', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-started', 'started-web'), nodeId: 1, envelope: env('op-act-started') }); + tx.reserveReconcileAttempt('app-started', env('op-started-1')); + + expect(store.getStartedAttempt('app-started', 'op-started-1')).toBeDefined(); + expect(store.getStartedAttempt('app-started', 'op-never-reserved')).toBeUndefined(); + }); + + it('pages past a permanently unsettled row instead of returning it forever on every call with the same cursor', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-cursor', 'cursor-web'), nodeId: 1, envelope: env('op-act-cursor') }); + tx.reserveReconcileAttempt('app-cursor', { operationId: 'op-cursor-stuck', actor: 'tester', trigger: 'manual', at: 1_000 }); + tx.reserveReconcileAttempt('app-cursor', { operationId: 'op-cursor-next', actor: 'tester', trigger: 'manual', at: 2_000 }); + + const firstPage = store.listUnsettledReconcileAttempts(1); + expect(firstPage.map((r) => r.operation_id)).toEqual(['op-cursor-stuck']); + const cursor = { createdAt: firstPage[0].created_at, id: firstPage[0].id }; + // Simulate op-cursor-stuck being permanently unrecoverable: it is never + // settled, so a caller must page past it using the cursor rather than + // seeing it again on the next call. + const secondPage = store.listUnsettledReconcileAttempts(1, cursor); + expect(secondPage.map((r) => r.operation_id)).toEqual(['op-cursor-next']); + }); + + it('allocates a fresh attemptSeq-derived operation id and reserves it in one transaction', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-alloc', 'alloc-web'), nodeId: 1, envelope: env('op-act-alloc') }); + const before = store.getApplication('app-alloc')!; + + const first = tx.allocateReconcileAttempt('app-alloc', 'tester', 'manual', Date.now()); + const second = tx.allocateReconcileAttempt('app-alloc', 'tester', 'manual', Date.now()); + + expect(first.reserved).toBe(true); + expect(second.reserved).toBe(true); + expect(first.operationId).not.toBe(second.operationId); + const after = store.getApplication('app-alloc')!; + expect(after.attempt_seq).toBe(before.attempt_seq + 2); + }); + + it('rolls back the allocated sequence when reservation insertion fails', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-alloc-rollback', 'alloc-rollback-web'), nodeId: 1, envelope: env('op-act-alloc-rollback') }); + const db = DatabaseService.getInstance().getDb(); + const before = store.getApplication('app-alloc-rollback')!; + db.exec(` + CREATE TRIGGER fail_reconcile_reservation + BEFORE INSERT ON gitops_history + WHEN NEW.stage = 'source_reconcile_started' + BEGIN + SELECT RAISE(ABORT, 'simulated reservation insert failure'); + END + `); + + try { + expect(() => tx.allocateReconcileAttempt('app-alloc-rollback', 'tester', 'manual', Date.now())) + .toThrow('simulated reservation insert failure'); + expect(store.getApplication('app-alloc-rollback')!.attempt_seq).toBe(before.attempt_seq); + expect(store.listUnsettledReconcileAttempts().some((row) => row.application_id === 'app-alloc-rollback')).toBe(false); + } finally { + db.exec('DROP TRIGGER fail_reconcile_reservation'); + } + }); + + it('records a follower link on a reservation made on behalf of a coalesced request', () => { + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-follower', 'follower-web'), nodeId: 1, envelope: env('op-act-follower') }); + + const leader = tx.allocateReconcileAttempt('app-follower', 'tester', 'manual', Date.now()); + const follower = tx.allocateReconcileAttempt('app-follower', 'tester', 'manual', Date.now(), leader.operationId); + + expect(follower.reserved).toBe(true); + const started = DatabaseService.getInstance().getDb() + .prepare("SELECT after_json FROM gitops_history WHERE application_id = ? AND operation_id = ? AND stage = 'source_reconcile_started'") + .get('app-follower', follower.operationId) as { after_json: string }; + expect(JSON.parse(started.after_json)).toEqual({ followerOf: leader.operationId }); + }); + + it('records the original webhook delivery intent on its stable reservation', () => { + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-delivery-intent', 'delivery-intent-web'), nodeId: 1, envelope: env('op-act-delivery-intent') }); + + tx.reserveReconcileAttempt( + 'app-delivery-intent', + env('webhook:fetch:delivery-intent'), + undefined, + { autoApply: true, deploy: false }, + ); + + const started = GitOpsStore.getInstance().getStartedAttempt('app-delivery-intent', 'webhook:fetch:delivery-intent')!; + expect(JSON.parse(started.after_json)).toEqual({ + deliveryIntent: { autoApply: true, deploy: false }, + }); + }); + + it('reports the most recently settled attempt even when both share the same millisecond timestamp', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + tx.activateDirect({ application: app('app-latest', 'latest-web'), nodeId: 1, envelope: env('op-act-latest') }); + // Same `at` on purpose: created_at alone cannot tell these two apart, + // so the query must break the tie by insertion order (rowid), not the + // id column, which is a random UUID unrelated to recency. + const sameInstant = 5_000; + tx.reserveReconcileAttempt('app-latest', { operationId: 'op-latest-1', actor: 'tester', trigger: 'manual', at: sameInstant }); + tx.settleReconcileAttempt( + 'app-latest', + { operationId: 'op-latest-1', actor: 'tester', trigger: 'manual', at: sameInstant }, + { outcome: 'no_source_change', reason: 'first', nextAction: 'none' }, + ); + tx.reserveReconcileAttempt('app-latest', { operationId: 'op-latest-2', actor: 'tester', trigger: 'manual', at: sameInstant }); + tx.settleReconcileAttempt( + 'app-latest', + { operationId: 'op-latest-2', actor: 'tester', trigger: 'manual', at: sameInstant }, + { outcome: 'retry_scheduled', reason: 'second', nextAction: 'none' }, + ); + + const latest = store.latestSettledAttempt('app-latest'); + expect(latest?.operation_id).toBe('op-latest-2'); + }); +}); + +describe('poll and retry eligibility queries', () => { + it('lists a source whose next_poll_at has arrived', () => { + const store = GitOpsStore.getInstance(); + store.insertApplication({ ...app('app-poll-due', 'poll-due-web'), next_poll_at: 1_000 }); + const due = store.listSourcesDueForPoll(1_000); + expect(due.map((a) => a.id)).toContain('app-poll-due'); + }); + + it('excludes a source whose next_poll_at has not arrived yet', () => { + const store = GitOpsStore.getInstance(); + store.insertApplication({ ...app('app-poll-future', 'poll-future-web'), next_poll_at: 5_000 }); + const due = store.listSourcesDueForPoll(1_000); + expect(due.map((a) => a.id)).not.toContain('app-poll-future'); + }); + + it('excludes a suspended source even when its poll time has arrived', () => { + const store = GitOpsStore.getInstance(); + store.insertApplication({ ...app('app-poll-susp', 'poll-susp-web'), next_poll_at: 1_000, suspended_at: 500 }); + const due = store.listSourcesDueForPoll(1_000); + expect(due.map((a) => a.id)).not.toContain('app-poll-susp'); + }); + + it('excludes a source with an operation already in flight', () => { + const store = GitOpsStore.getInstance(); + store.insertApplication({ + ...app('app-poll-busy', 'poll-busy-web'), + next_poll_at: 1_000, + active_operation_stage: 'fetch_started', + }); + const due = store.listSourcesDueForPoll(1_000); + expect(due.map((a) => a.id)).not.toContain('app-poll-busy'); + }); + + it('excludes a Blueprint-mode application from polling', () => { + const store = GitOpsStore.getInstance(); + store.insertApplication({ + ...app('app-poll-bp', 'unused-bp'), + stack_name: null, + blueprint_id: 42, + target_mode: 'blueprint', + configured_repo_url: 'https://github.com/org/repo.git', + next_poll_at: 1_000, + }); + const due = store.listSourcesDueForPoll(1_000); + expect(due.map((a) => a.id)).not.toContain('app-poll-bp'); + }); + + it('lists an application whose retry_at has arrived', () => { + const store = GitOpsStore.getInstance(); + store.insertApplication({ ...app('app-retry-due', 'retry-due-web'), retry_at: 1_000 }); + const due = store.listApplicationsDueForRetry(1_000); + expect(due.map((a) => a.id)).toContain('app-retry-due'); + }); + + it('excludes an application with no retry scheduled', () => { + const store = GitOpsStore.getInstance(); + store.insertApplication(app('app-retry-none', 'retry-none-web')); + const due = store.listApplicationsDueForRetry(1_000); + expect(due.map((a) => a.id)).not.toContain('app-retry-none'); + }); + + it('excludes a suspended application even when its retry time has arrived', () => { + const store = GitOpsStore.getInstance(); + store.insertApplication({ ...app('app-retry-susp', 'retry-susp-web'), retry_at: 1_000, suspended_at: 500 }); + const due = store.listApplicationsDueForRetry(1_000); + expect(due.map((a) => a.id)).not.toContain('app-retry-susp'); + }); +}); + +function env(operationId: string): EventEnvelope { + return { operationId, actor: 'tester', trigger: 'manual', at: Date.now() }; +} + +function app(id: string, stackName: string): GitOpsApplicationRow { + return { + id, + lifecycle_key: `direct:${stackName}`, + lifecycle_status: 'active', + target_mode: 'direct', + stack_name: stackName, + blueprint_id: null, + configured_repo_url: 'https://github.com/org/repo.git', + repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}', + configured_ref: 'main', + compose_paths_json: '["compose.yml"]', + context_dir: null, + sync_env: 0, + env_path: null, + materialization_fingerprint: 'a'.repeat(64), + desired_commit_sha: null, + fetched_commit_sha: null, + fetched_resolved_ref_kind: null, + candidate_generation_id: null, + accepted_generation_id: null, + candidate_plan_blocked: 0, + review_required: 0, + artifact_set_id: null, + latest_artifact_set_id: null, + intent_revision_id: null, + rollout_candidate_id: null, + rollout_generation_id: null, + source_acceptance_ref: null, + placement_approval_ref: null, + rollout_authorization_ref: null, + legacy_combined_approval_ref: null, + preflight_fingerprint: null, + latest_operation_id: null, + active_operation_id: null, + active_operation_stage: null, + active_operation_at: null, + active_generation_id: null, + pause_at: null, + pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, + partial_json: null, + failure_stage: null, + failure_class: null, + failure_at: null, + retry_at: null, + retry_count: 0, + suspended_at: null, + recovery_ref: null, + recovery_phase: null, + interruption_stage: null, + interruption_at: null, + interruption_operation_id: null, + interruption_generation_id: null, + evidence_fresh_at: null, + evidence_limitations_json: null, + created_at: 1, + updated_at: 1, + }; +} diff --git a/backend/src/__tests__/gitops-recovery-capture.test.ts b/backend/src/__tests__/gitops-recovery-capture.test.ts index 6f76f879..ef76d704 100644 --- a/backend/src/__tests__/gitops-recovery-capture.test.ts +++ b/backend/src/__tests__/gitops-recovery-capture.test.ts @@ -146,6 +146,11 @@ function app(id: string, stackName: string): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, @@ -188,6 +193,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow { actor: 'tester', previous_generation_id: null, redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, created_at: 1, }; } diff --git a/backend/src/__tests__/gitops-recovery.test.ts b/backend/src/__tests__/gitops-recovery.test.ts index e0891847..e754cf71 100644 --- a/backend/src/__tests__/gitops-recovery.test.ts +++ b/backend/src/__tests__/gitops-recovery.test.ts @@ -460,6 +460,11 @@ function app(id: string, stackName: string): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, @@ -502,6 +507,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow { actor: 'tester', previous_generation_id: null, redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, created_at: 1, }; } diff --git a/backend/src/__tests__/gitops-schema.test.ts b/backend/src/__tests__/gitops-schema.test.ts index a75132df..11dbcd45 100644 --- a/backend/src/__tests__/gitops-schema.test.ts +++ b/backend/src/__tests__/gitops-schema.test.ts @@ -239,6 +239,61 @@ describe('gitops schema', () => { expect(row?.configured_ref).toBe('v1'); }); + it('round-trips the portable generation contract fields, defaulting to null for legacy rows', async () => { + const store = GitOpsStore.getInstance(); + store.insertApplication(directApp('app-portable', 'portable-web')); + store.insertGeneration({ + ...generation('gen-portable-legacy', 'app-portable'), + }); + const legacy = store.getGeneration('gen-portable-legacy'); + expect(legacy?.portable_manifest_json).toBeNull(); + expect(legacy?.compose_inputs_json).toBeNull(); + expect(legacy?.source_policy_evidence_json).toBeNull(); + expect(legacy?.security_policy_evidence_json).toBeNull(); + expect(legacy?.support_requirements_json).toBeNull(); + expect(legacy?.compatibility_requirements_json).toBeNull(); + + store.insertGeneration({ + ...generation('gen-portable-new', 'app-portable'), + portable_manifest_json: '{"files":[]}', + compose_inputs_json: '{"composeFileOrder":["compose.yaml"]}', + source_policy_evidence_json: '{"policy":"manual"}', + security_policy_evidence_json: '{"status":"allowed"}', + support_requirements_json: '{}', + compatibility_requirements_json: '{}', + }); + const populated = store.getGeneration('gen-portable-new'); + expect(populated?.portable_manifest_json).toBe('{"files":[]}'); + expect(populated?.compose_inputs_json).toBe('{"composeFileOrder":["compose.yaml"]}'); + expect(populated?.source_policy_evidence_json).toBe('{"policy":"manual"}'); + }); + + it('defaults controller-owned columns to manual, off, and zero on a fresh application', async () => { + const store = GitOpsStore.getInstance(); + store.insertApplication(directApp('app-ctrl', 'ctrl-web')); + const app = store.getApplication('app-ctrl'); + expect(app?.source_policy).toBe('manual'); + expect(app?.poll_interval_secs).toBeNull(); + expect(app?.next_poll_at).toBeNull(); + expect(app?.attempt_seq).toBe(0); + }); + + it('round-trips a configured poll interval and policy', async () => { + const store = GitOpsStore.getInstance(); + store.insertApplication({ + ...directApp('app-ctrl2', 'ctrl2-web'), + source_policy: 'automatic', + poll_interval_secs: 120, + next_poll_at: 5000, + attempt_seq: 3, + }); + const app = store.getApplication('app-ctrl2'); + expect(app?.source_policy).toBe('automatic'); + expect(app?.poll_interval_secs).toBe(120); + expect(app?.next_poll_at).toBe(5000); + expect(app?.attempt_seq).toBe(3); + }); + it('round-trips fetched_resolved_ref_kind on application fetch transitions', async () => { const store = GitOpsStore.getInstance(); store.insertApplication(directApp('app-fetch-kind', 'fetch-web')); @@ -293,6 +348,11 @@ function directApp(id: string, stackName: string): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, @@ -361,6 +421,12 @@ function generation(id: string, applicationId: string): GitOpsGenerationRow { actor: 'tester', previous_generation_id: null, redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, created_at: 1, }; } diff --git a/backend/src/__tests__/gitops-transitions.test.ts b/backend/src/__tests__/gitops-transitions.test.ts index b7d3a6b6..8cc883ae 100644 --- a/backend/src/__tests__/gitops-transitions.test.ts +++ b/backend/src/__tests__/gitops-transitions.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import { DatabaseService } from '../services/DatabaseService'; import { encodeArtifactEvidenceJson } from '../services/gitops/json'; -import { GitOpsStore } from '../services/gitops/store'; +import { GitOpsStore, emptyTargetRow } from '../services/gitops/store'; import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions'; import { projectApplication } from '../services/gitops/derive'; import type { GitOpsApplicationRow, GitOpsGenerationRow } from '../services/gitops/types'; @@ -520,6 +520,222 @@ describe('gitops transitions', () => { expect(store.getTarget('app-int', 1)?.deployed_generation_id).toBe('gen-int'); expect(store.getTarget('app-int', 1)?.failure_stage).toBeNull(); }); + + it('sourceAccepted accepts the generation without touching any target', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + const env = envelope('op-sa'); + tx.activateDirect({ application: app('app-sa', 'sa-web'), nodeId: 1, envelope: env }); + store.insertGeneration(gen('gen-sa', 'app-sa')); + tx.fetchStarted('app-sa', envelope('op-f-sa')); + tx.fetched('app-sa', 'deadbeef', envelope('op-f-sa')); + tx.candidateReady('app-sa', 'gen-sa', false, envelope('op-c-sa')); + tx.applyStarted('app-sa', 'gen-sa', envelope('op-sa')); + + const result = tx.sourceAccepted({ + applicationId: 'app-sa', + generationId: 'gen-sa', + artifactSetId: 'art-sa', + sourceAcceptanceId: 'acc-sa', + authority: 'operator', + envelope: env, + }); + + expect(result.replayed).toBe(false); + const application = store.getApplication('app-sa')!; + expect(application.accepted_generation_id).toBe('gen-sa'); + expect(application.source_acceptance_ref).toBe('acc-sa'); + expect(application.candidate_generation_id).toBeNull(); + // sourceAccepted is mode-neutral: it must not bind the Direct target. + const target = store.getTarget('app-sa', 1)!; + expect(target.applied_generation_id).toBeNull(); + expect(target.desired_generation_id).toBeNull(); + expect(target.candidate_generation_id).toBe('gen-sa'); + }); + + it('sourceAccepted refuses to accept a candidate while the source is suspended', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + const env = envelope('op-sas'); + tx.activateDirect({ application: app('app-sas', 'sas-web'), nodeId: 1, envelope: env }); + store.insertGeneration(gen('gen-sas', 'app-sas')); + tx.fetchStarted('app-sas', envelope('op-f-sas')); + tx.fetched('app-sas', 'deadbeef', envelope('op-f-sas')); + tx.candidateReady('app-sas', 'gen-sas', false, envelope('op-c-sas')); + tx.applyStarted('app-sas', 'gen-sas', envelope('op-sas')); + tx.sourceSuspended('app-sas', 'operator paused sync', envelope('op-susp-sas')); + + expect(() => tx.sourceAccepted({ + applicationId: 'app-sas', + generationId: 'gen-sas', + artifactSetId: 'art-sas', + sourceAcceptanceId: 'acc-sas', + authority: 'operator', + envelope: envelope('op-sas-2'), + })).toThrow(/suspended/); + + const application = store.getApplication('app-sas')!; + expect(application.accepted_generation_id).toBeNull(); + expect(application.candidate_generation_id).toBe('gen-sas'); + expect(application.suspended_at).not.toBeNull(); + }); + + it('targetApplied binds a Direct target only after the generation is accepted', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + const env = envelope('op-ta'); + tx.activateDirect({ application: app('app-ta', 'ta-web'), nodeId: 1, envelope: env }); + store.insertGeneration(gen('gen-ta', 'app-ta')); + tx.fetchStarted('app-ta', envelope('op-f-ta')); + tx.fetched('app-ta', 'deadbeef', envelope('op-f-ta')); + tx.candidateReady('app-ta', 'gen-ta', false, envelope('op-c-ta')); + tx.applyStarted('app-ta', 'gen-ta', envelope('op-ta')); + tx.sourceAccepted({ + applicationId: 'app-ta', + generationId: 'gen-ta', + artifactSetId: 'art-ta', + sourceAcceptanceId: 'acc-ta', + authority: 'operator', + envelope: env, + }); + + const result = tx.targetApplied(1, { + applicationId: 'app-ta', + generationId: 'gen-ta', + artifactSetId: 'art-ta', + sourceAcceptanceId: 'acc-ta', + authority: 'operator', + envelope: env, + }); + + expect(result.replayed).toBe(false); + const target = store.getTarget('app-ta', 1)!; + expect(target.applied_generation_id).toBe('gen-ta'); + expect(target.desired_generation_id).toBe('gen-ta'); + expect(target.candidate_generation_id).toBeNull(); + expect(target.expected_artifact_set_id).toBe('art-ta'); + expect(target.source_acceptance_ref).toBe('acc-ta'); + }); + + it('targetApplied refuses a delayed dispatch that would erase a newer candidate', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + const env = envelope('op-tan'); + tx.activateDirect({ application: app('app-tan', 'tan-web'), nodeId: 1, envelope: env }); + store.insertGeneration(gen('gen-tan-1', 'app-tan')); + tx.fetchStarted('app-tan', envelope('op-f-tan-1')); + tx.fetched('app-tan', 'deadbeef', envelope('op-f-tan-1')); + tx.candidateReady('app-tan', 'gen-tan-1', false, envelope('op-c-tan-1')); + tx.applyStarted('app-tan', 'gen-tan-1', envelope('op-tan')); + tx.sourceAccepted({ + applicationId: 'app-tan', + generationId: 'gen-tan-1', + artifactSetId: 'art-tan-1', + sourceAcceptanceId: 'acc-tan-1', + authority: 'operator', + envelope: env, + }); + // A newer revision arrives and supersedes generation 1 as the target's + // current candidate, before generation 1's dispatch ever binds a target. + store.insertGeneration(gen('gen-tan-2', 'app-tan')); + tx.fetchStarted('app-tan', envelope('op-f-tan-2')); + tx.fetched('app-tan', 'cafed00d', envelope('op-f-tan-2')); + tx.candidateReady('app-tan', 'gen-tan-2', false, envelope('op-c-tan-2')); + + // Generation 1's delayed dispatch must not silently erase generation 2's + // candidate, even though generation 1 is (still) the accepted generation. + expect(() => tx.targetApplied(1, { + applicationId: 'app-tan', + generationId: 'gen-tan-1', + artifactSetId: 'art-tan-1', + sourceAcceptanceId: 'acc-tan-1', + authority: 'operator', + envelope: envelope('op-ta-delayed'), + })).toThrow(/candidate/); + + const target = store.getTarget('app-tan', 1)!; + expect(target.candidate_generation_id).toBe('gen-tan-2'); + expect(target.applied_generation_id).toBeNull(); + }); + + it('targetApplied refuses a source acceptance reference that does not match what was accepted', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + const env = envelope('op-tar2'); + tx.activateDirect({ application: app('app-tar2', 'tar2-web'), nodeId: 1, envelope: env }); + store.insertGeneration(gen('gen-tar2', 'app-tar2')); + tx.fetchStarted('app-tar2', envelope('op-f-tar2')); + tx.fetched('app-tar2', 'deadbeef', envelope('op-f-tar2')); + tx.candidateReady('app-tar2', 'gen-tar2', false, envelope('op-c-tar2')); + tx.applyStarted('app-tar2', 'gen-tar2', envelope('op-tar2')); + tx.sourceAccepted({ + applicationId: 'app-tar2', + generationId: 'gen-tar2', + artifactSetId: 'art-tar2', + sourceAcceptanceId: 'acc-tar2-real', + authority: 'operator', + envelope: env, + }); + + expect(() => tx.targetApplied(1, { + applicationId: 'app-tar2', + generationId: 'gen-tar2', + artifactSetId: 'art-tar2', + sourceAcceptanceId: 'acc-tar2-forged', + authority: 'operator', + envelope: envelope('op-ta-forged'), + })).toThrow(/acceptance/); + + const target = store.getTarget('app-tar2', 1)!; + expect(target.source_acceptance_ref).not.toBe('acc-tar2-forged'); + }); + + it('targetApplied refuses to bind a target on a non-Direct application', async () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + const { blankInlineApplication } = await import('../services/gitops/blueprintProducers'); + const env = envelope('op-tam'); + + // A genuine Blueprint application: no Git identity, so the "accepted + // generation" and target row below are constructed directly rather than + // through the Direct fetch/candidate/apply path, which this mode does + // not have. + tx.activateInlineBlueprint({ application: blankInlineApplication('app-tam', 900, env.at), envelope: env }); + store.insertGeneration(gen('gen-tam', 'app-tam')); + DatabaseService.getInstance().getDb().prepare( + "UPDATE gitops_applications SET accepted_generation_id = 'gen-tam' WHERE id = 'app-tam'", + ).run(); + store.upsertTarget(emptyTargetRow('app-tam', 1, env.at)); + + expect(() => tx.targetApplied(1, { + applicationId: 'app-tam', + generationId: 'gen-tam', + artifactSetId: 'art-tam', + sourceAcceptanceId: 'acc-tam', + authority: 'operator', + envelope: env, + })).toThrow(/direct/i); + }); + + it('targetApplied refuses a generation the application has not accepted', () => { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + const env = envelope('op-tar'); + tx.activateDirect({ application: app('app-tar', 'tar-web'), nodeId: 1, envelope: env }); + store.insertGeneration(gen('gen-tar', 'app-tar')); + tx.fetchStarted('app-tar', envelope('op-f-tar')); + tx.fetched('app-tar', 'deadbeef', envelope('op-f-tar')); + tx.candidateReady('app-tar', 'gen-tar', false, envelope('op-c-tar')); + + expect(() => tx.targetApplied(1, { + applicationId: 'app-tar', + generationId: 'gen-tar', + artifactSetId: 'art-tar', + sourceAcceptanceId: 'acc-tar', + authority: 'operator', + envelope: env, + })).toThrow(/not accepted/); + }); }); function mustProject(applicationId: string) { @@ -596,6 +812,11 @@ function app(id: string, stackName: string): GitOpsApplicationRow { active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, @@ -638,6 +859,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow { actor: 'tester', previous_generation_id: null, redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, created_at: 1, }; } diff --git a/backend/src/__tests__/gitops-triggers.test.ts b/backend/src/__tests__/gitops-triggers.test.ts new file mode 100644 index 00000000..7577e5db --- /dev/null +++ b/backend/src/__tests__/gitops-triggers.test.ts @@ -0,0 +1,85 @@ +/** + * Pure trigger normalization and coalescing-key coverage for the GitOps + * source controller. No DB, no store: these are the identity/joining rules + * a controller submission goes through before anything durable happens. + */ +import { describe, it, expect } from 'vitest'; +import { coalesceKey, deliveryKey, type ReconcileRequest } from '../services/gitops/triggers'; + +function fetchRequest(overrides: Partial> = {}): ReconcileRequest { + return { + intent: 'fetch', + applicationId: 'app-1', + stackName: 'web', + trigger: 'manual', + actor: 'tester', + ...overrides, + }; +} + +function applyRequest(overrides: Partial> = {}): ReconcileRequest { + return { + intent: 'apply', + applicationId: 'app-1', + stackName: 'web', + trigger: 'manual', + actor: 'tester', + commitSha: 'a'.repeat(40), + planFingerprint: 'fp-1', + deploy: false, + ...overrides, + }; +} + +describe('coalesceKey', () => { + it('joins two fetch requests for the same application', () => { + expect(coalesceKey(fetchRequest())).toBe(coalesceKey(fetchRequest({ trigger: 'poll' }))); + }); + + it('does not join fetch requests for different applications', () => { + expect(coalesceKey(fetchRequest({ applicationId: 'app-1' }))) + .not.toBe(coalesceKey(fetchRequest({ applicationId: 'app-2' }))); + }); + + it('joins two apply requests with identical commit, fingerprint, and deploy flag', () => { + expect(coalesceKey(applyRequest())).toBe(coalesceKey(applyRequest({ trigger: 'webhook' }))); + }); + + it('does not join two applies with different plan fingerprints', () => { + expect(coalesceKey(applyRequest({ planFingerprint: 'fp-1' }))) + .not.toBe(coalesceKey(applyRequest({ planFingerprint: 'fp-2' }))); + }); + + it('does not join two applies with different commits', () => { + expect(coalesceKey(applyRequest({ commitSha: 'a'.repeat(40) }))) + .not.toBe(coalesceKey(applyRequest({ commitSha: 'b'.repeat(40) }))); + }); + + it('does not join two applies that differ only in deploy', () => { + expect(coalesceKey(applyRequest({ deploy: false }))) + .not.toBe(coalesceKey(applyRequest({ deploy: true }))); + }); + + it('never joins a fetch and an apply for the same application', () => { + expect(coalesceKey(fetchRequest())).not.toBe(coalesceKey(applyRequest())); + }); + + it('does not join two fetches for the same applicationId but different stack names', () => { + expect(coalesceKey(fetchRequest({ stackName: 'web' }))) + .not.toBe(coalesceKey(fetchRequest({ stackName: 'other-stack' }))); + }); +}); + +describe('deliveryKey', () => { + it('namespaces the same delivery id differently per trigger', () => { + expect(deliveryKey('webhook', 'fetch', 'delivery-1')).not.toBe(deliveryKey('api', 'fetch', 'delivery-1')); + }); + + it('namespaces the same delivery id differently per intent', () => { + expect(deliveryKey('webhook', 'fetch', 'delivery-1')).not.toBe(deliveryKey('webhook', 'apply', 'delivery-1')); + }); + + it('is stable for the same trigger, intent, and delivery id', () => { + expect(deliveryKey('webhook', 'fetch', 'delivery-1')).toBe(deliveryKey('webhook', 'fetch', 'delivery-1')); + }); +}); diff --git a/backend/src/__tests__/helpers/allowLoopbackTargets.ts b/backend/src/__tests__/helpers/allowLoopbackTargets.ts new file mode 100644 index 00000000..127f05d8 --- /dev/null +++ b/backend/src/__tests__/helpers/allowLoopbackTargets.ts @@ -0,0 +1,98 @@ +import { vi } from 'vitest'; +import http from 'http'; +import https from 'https'; +import type { LookupFunction } from 'net'; +import { AsyncLocalStorage } from 'async_hooks'; + +const targetProtectionScope = new AsyncLocalStorage(); + +export async function withLoopbackTargetProtection(action: () => PromiseLike): Promise { + return targetProtectionScope.run(false, async () => action()); +} + +vi.mock('../../utils/outboundTarget', async (importOriginal) => { + const actual = await importOriginal(); + const fixtureAllows = (hostname: string): boolean => { + if (targetProtectionScope.getStore() === false) return false; + const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + return normalized === 'localhost' + || normalized === '::1' + || normalized.startsWith('127.') + || normalized.startsWith('10.') + || normalized.startsWith('192.168.') + || /^172\.(1[6-9]|2\d|3[01])\./.test(normalized) + || normalized.endsWith('.example.com') + || normalized.endsWith('.example') + || normalized.endsWith('.invalid') + || normalized.endsWith('.local') + || normalized === 'remote' + || normalized === 'remote2' + || normalized === 'good-host' + || normalized === 'bad-host'; + }; + const fixtureAddress = (hostname: string): string => { + const normalized = hostname.replace(/^\[|\]$/g, ''); + if (normalized === 'localhost') return '127.0.0.1'; + if (normalized === 'pinned.example') return '93.184.216.34'; + return normalized; + }; + const fixtureLookup: LookupFunction = (hostname, options, callback): void => { + if (!fixtureAllows(hostname)) { + actual.safeOutboundLookup(hostname, options, callback); + return; + } + const normalized = hostname.replace(/^\[|\]$/g, ''); + const address = fixtureAddress(hostname); + const family = normalized === '::1' ? 6 : 4; + if (options.all) callback(null, [{ address, family }]); + else callback(null, address, family); + }; + + const fixtureHttpAgent = new http.Agent({ lookup: fixtureLookup }); + const fixtureHttpsAgent = new https.Agent({ lookup: fixtureLookup }); + + return { + ...actual, + assertSafeOutboundHostname: async ( + hostname: string, + ): Promise => { + if (fixtureAllows(hostname)) return; + await actual.assertSafeOutboundHostname(hostname); + }, + assertSafeOutboundUrl: async ( + raw: string, + ): Promise => { + const url = new URL(raw); + if (fixtureAllows(url.hostname)) return url; + return actual.assertSafeOutboundUrl(raw); + }, + resolveSafeOutboundHostname: async (hostname: string) => { + if (fixtureAllows(hostname)) { + const normalized = hostname.replace(/^\[|\]$/g, ''); + return [{ address: fixtureAddress(hostname), family: normalized === '::1' ? 6 : 4 }]; + } + return actual.resolveSafeOutboundHostname(hostname); + }, + safeOutboundLookup: fixtureLookup, + safeHttpAgent: fixtureHttpAgent, + safeHttpsAgent: fixtureHttpsAgent, + safeAxiosTransport: (trustedLoopback = false) => ({ + maxRedirects: 0, + proxy: false, + ...(trustedLoopback ? {} : { httpAgent: fixtureHttpAgent, httpsAgent: fixtureHttpsAgent }), + }), + safeRemoteFetch: async ( + input: Parameters[0], + init?: Parameters[1], + trustedLoopback?: boolean, + ) => { + const url = input instanceof URL + ? input + : new URL(typeof input === 'string' ? input : input.url); + if (fixtureAllows(url.hostname)) { + return globalThis.fetch(input, { ...init, redirect: 'error' }); + } + return actual.safeRemoteFetch(input, init, trustedLoopback); + }, + }; +}); diff --git a/backend/src/__tests__/helpers/gitopsFixtures.ts b/backend/src/__tests__/helpers/gitopsFixtures.ts index 242f5a77..71d08ebc 100644 --- a/backend/src/__tests__/helpers/gitopsFixtures.ts +++ b/backend/src/__tests__/helpers/gitopsFixtures.ts @@ -56,6 +56,11 @@ export function directApplicationFixture(id: string, stackName: string): GitOpsA active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, diff --git a/backend/src/__tests__/hub-only-guard.test.ts b/backend/src/__tests__/hub-only-guard.test.ts index b9c1e965..6db8e47b 100644 --- a/backend/src/__tests__/hub-only-guard.test.ts +++ b/backend/src/__tests__/hub-only-guard.test.ts @@ -368,4 +368,108 @@ describe('hubOnlyGuard', () => { expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); expect(res.status).not.toBe(503); }); + + // Regression for api-tokens hub-only boundary: /api/api-tokens manages + // sensitive bearer material and must stay hub-only. + it('rejects /api/api-tokens (GET proxy) with 403', async () => { + const res = await request(app) + .get('/api/api-tokens') + .set('Authorization', authHeader) + .set('x-node-id', String(proxyRemoteNodeId)); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('rejects /api/api-tokens (POST proxy) with 403', async () => { + const res = await request(app) + .post('/api/api-tokens') + .set('Authorization', authHeader) + .set('x-node-id', String(proxyRemoteNodeId)); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('rejects /api/api-tokens/:id (DELETE proxy) with 403', async () => { + const res = await request(app) + .delete('/api/api-tokens/1') + .set('Authorization', authHeader) + .set('x-node-id', String(proxyRemoteNodeId)); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('rejects /api/api-tokens (GET pilot_agent) with 403', async () => { + const res = await request(app) + .get('/api/api-tokens') + .set('Authorization', authHeader) + .set('x-node-id', String(pilotRemoteNodeId)); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('rejects /api/api-tokens (POST pilot_agent) with 403', async () => { + const res = await request(app) + .post('/api/api-tokens') + .set('Authorization', authHeader) + .set('x-node-id', String(pilotRemoteNodeId)); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('rejects /api/api-tokens/:id (DELETE pilot_agent) with 403', async () => { + const res = await request(app) + .delete('/api/api-tokens/1') + .set('Authorization', authHeader) + .set('x-node-id', String(pilotRemoteNodeId)); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('rejects /api/api-tokens via ?nodeId= (Pilot mode, GET)', async () => { + const res = await request(app) + .get(`/api/api-tokens?nodeId=${pilotRemoteNodeId}`) + .set('Authorization', authHeader); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('rejects mixed-case /api/API-Tokens with 403 for proxy', async () => { + const res = await request(app) + .get('/api/API-Tokens') + .set('Authorization', authHeader) + .set('x-node-id', String(proxyRemoteNodeId)); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('lets /api/api-tokens through locally (hub-local GET)', async () => { + const res = await request(app) + .get('/api/api-tokens') + .set('Authorization', authHeader); + expect(res.status).toBe(200); + }); + + it('lets /api/api-tokens through locally (hub-local POST creates token)', async () => { + const res = await request(app) + .post('/api/api-tokens') + .set('Authorization', authHeader) + .send({ name: 'local-test', scope: 'read-only', expires_in: 30 }); + expect(res.status).toBe(201); + expect(res.body.id).toBeDefined(); + }); + + it('lets /api/api-tokens through locally and deletes created token (hub-local DELETE)', async () => { + // Create, then delete using the returned ID for a deterministic lifecycle. + const postRes = await request(app) + .post('/api/api-tokens') + .set('Authorization', authHeader) + .send({ name: 'local-delete-test', scope: 'full-admin', expires_in: 30 }); + expect(postRes.status).toBe(201); + expect(postRes.body.id).toBeDefined(); + + const delRes = await request(app) + .delete(`/api/api-tokens/${postRes.body.id}`) + .set('Authorization', authHeader); + expect(delRes.status).toBe(200); + }); }); diff --git a/backend/src/__tests__/label-inventory.test.ts b/backend/src/__tests__/label-inventory.test.ts index 2dcac783..dcfe5da3 100644 --- a/backend/src/__tests__/label-inventory.test.ts +++ b/backend/src/__tests__/label-inventory.test.ts @@ -493,7 +493,7 @@ describe('GET /api/fleet/container-labels', () => { ]); const db = DatabaseService.getInstance(); const remoteId = db.addNode({ name: 'remote-lbl', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 502 })); try { const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie); @@ -511,7 +511,7 @@ describe('GET /api/fleet/container-labels', () => { ]); const db = DatabaseService.getInstance(); const remoteId = db.addNode({ name: 'remote-bad', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null); // byLabel row missing valid key/value/source: must be rejected by the deep guard. const malformed = JSON.stringify({ nodeId: remoteId, containers: [], byLabel: [{ key: 123, containers: [] }], partial: false, generatedAt: 0 }); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(malformed, { status: 200, headers: { 'content-type': 'application/json' } })); @@ -528,7 +528,7 @@ describe('GET /api/fleet/container-labels', () => { stubDockerList([]); const db = DatabaseService.getInstance(); const remoteId = db.addNode({ name: 'remote-src', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null); const badSource = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'not-a-source', containers: [{ id: 'c', name: 'n' }] }] }); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badSource, { status: 200, headers: { 'content-type': 'application/json' } })); try { @@ -544,7 +544,7 @@ describe('GET /api/fleet/container-labels', () => { stubDockerList([]); const db = DatabaseService.getInstance(); const remoteId = db.addNode({ name: 'remote-cont', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null); const badContainer = JSON.stringify({ nodeId: remoteId, byLabel: [], partial: false, generatedAt: 0, containers: [{}] }); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badContainer, { status: 200, headers: { 'content-type': 'application/json' } })); try { @@ -560,7 +560,7 @@ describe('GET /api/fleet/container-labels', () => { stubDockerList([]); const db = DatabaseService.getInstance(); const remoteId = db.addNode({ name: 'remote-ref', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null); const badRef = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'runtime', containers: [{ id: 5 }] }] }); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badRef, { status: 200, headers: { 'content-type': 'application/json' } })); try { diff --git a/backend/src/__tests__/login-rate-limit.test.ts b/backend/src/__tests__/login-rate-limit.test.ts new file mode 100644 index 00000000..12ceae13 --- /dev/null +++ b/backend/src/__tests__/login-rate-limit.test.ts @@ -0,0 +1,66 @@ +import express from 'express'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + cleanupTestDb, + setupTestDb, + TEST_PASSWORD, + TEST_USERNAME, +} from './helpers/setupTestDb'; + +let authRouter: typeof import('../routes/auth').authRouter; +let tmpDir: string; +let app: import('express').Express; + +beforeAll(async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.resetModules(); + tmpDir = await setupTestDb(); + ({ authRouter } = await import('../routes/auth')); + + app = express(); + app.set('trust proxy', 1); + app.use(express.json()); + app.use('/api/auth', authRouter); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); + vi.unstubAllEnvs(); +}); + +describe('production login rate limiter', () => { + it('blocks repeated attempts from one source', async () => { + for (let attempt = 0; attempt < 5; attempt += 1) { + const failure = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.51.100.10') + .send({ username: TEST_USERNAME, password: 'wrong-password' }); + expect(failure.status).toBe(401); + } + + const blocked = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.51.100.10') + .send({ username: TEST_USERNAME, password: 'wrong-password' }); + + expect(blocked.status).toBe(429); + }); + + it('allows valid credentials after failures from other sources', async () => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const failure = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', `203.0.113.${attempt + 1}`) + .send({ username: TEST_USERNAME, password: 'wrong-password' }); + expect(failure.status).toBe(401); + } + + const validLogin = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', '203.0.113.250') + .send({ username: TEST_USERNAME, password: TEST_PASSWORD }); + + expect(validLogin.status).toBe(200); + }); +}); diff --git a/backend/src/__tests__/mesh-inspect-remote.test.ts b/backend/src/__tests__/mesh-inspect-remote.test.ts index f95c4ea1..e81e7a3d 100644 --- a/backend/src/__tests__/mesh-inspect-remote.test.ts +++ b/backend/src/__tests__/mesh-inspect-remote.test.ts @@ -75,6 +75,7 @@ describe('MeshService.inspectStackServices dispatch (C-3 fix)', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); const fetchMock = vi @@ -114,6 +115,7 @@ describe('MeshService.inspectStackServices dispatch (C-3 fix)', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Internal Server Error', { status: 500 })); diff --git a/backend/src/__tests__/mesh-list-stacks-remote.test.ts b/backend/src/__tests__/mesh-list-stacks-remote.test.ts index 1f39d1a7..3c72b695 100644 --- a/backend/src/__tests__/mesh-list-stacks-remote.test.ts +++ b/backend/src/__tests__/mesh-list-stacks-remote.test.ts @@ -73,6 +73,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); const fetchMock = vi @@ -112,6 +113,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Internal Server Error', { status: 500 })); @@ -160,6 +162,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( JSON.stringify({ stacks: ['ok-string', 42, null, { not: 'a string' }] }), @@ -223,6 +226,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); vi.spyOn(globalThis, 'fetch').mockImplementation(() => { throw new MeshError('push_failed', 'simulated transport failure'); diff --git a/backend/src/__tests__/mesh-proxy-tunnel-dial.test.ts b/backend/src/__tests__/mesh-proxy-tunnel-dial.test.ts index 0a430324..2768767b 100644 --- a/backend/src/__tests__/mesh-proxy-tunnel-dial.test.ts +++ b/backend/src/__tests__/mesh-proxy-tunnel-dial.test.ts @@ -9,6 +9,7 @@ */ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets'; let tmpDir: string; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; @@ -74,6 +75,28 @@ describe('MeshProxyTunnelDialer', () => { expect(dialer.hasBridge(nodeId)).toBe(false); }); + it('rejects an unsafe proxy target before opening a WebSocket', async () => { + const dialer = MeshProxyTunnelDialer.resetForTest(0); + const db = DatabaseService.getInstance(); + const nodeId = db.addNode({ + name: 'proxy-test-unsafe-target', + type: 'remote', + compose_dir: '', + is_default: false, + mode: 'proxy', + api_url: 'http://127.0.0.1:1852', + api_token: 'test-token', + }); + + const result = await withLoopbackTargetProtection(() => dialer.ensureBridge(nodeId)); + + expect(result).toBeNull(); + expect(dialer.getRecentFailure(nodeId)).toMatchObject({ + code: 'network_error', + message: 'The target address is not allowed.', + }); + }); + it('expires the recent-failure cache entry after the cache TTL window', async () => { const dialer = MeshProxyTunnelDialer.resetForTest(0); const result = await dialer.ensureBridge(8888); diff --git a/backend/src/__tests__/mesh-remove-override-remote.test.ts b/backend/src/__tests__/mesh-remove-override-remote.test.ts index a027ea9a..1a580d4b 100644 --- a/backend/src/__tests__/mesh-remove-override-remote.test.ts +++ b/backend/src/__tests__/mesh-remove-override-remote.test.ts @@ -47,6 +47,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); const fetchMock = vi @@ -82,6 +83,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); const fetchMock = vi @@ -112,6 +114,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED')); @@ -139,6 +142,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => { vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', + trustedLoopback: false, }); vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response('another operation is already in progress', { status: 500 }), diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts index a35e387b..2a1bea18 100644 --- a/backend/src/__tests__/monitor-service.test.ts +++ b/backend/src/__tests__/monitor-service.test.ts @@ -1621,6 +1621,41 @@ describe('MonitorService - Sencho dev build check', () => { expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything()); }); + + // C1: update eligibility is compose-declared. A dev *running* identity must + // not make a stable-declared pin eligible for the dev-build detector, and an + // unknown running identity must still gate before any registry call. These + // two disagreement directions pin that the running identity surfaced by + // SelfIdentityService never changes update behavior. + it('does not treat a stable-declared pin as eligible even when the running identity is a dev build', async () => { + // beforeEach() already mints a dev running imageId; the declared pin is stable. + // Reset the version-update inputs: an earlier suppression test queues a + // once-value that a stable-pin run would otherwise drain into the version + // path, which is not what this test observes. + mockGetLatestVersionInfo.mockReset(); + mockGetPinInfo.mockResolvedValue(STABLE_PIN); + + await runEvaluate(); + + expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled(); + expect(devBuildCalls()).toHaveLength(0); + expect(mockSetSystemState).not.toHaveBeenCalledWith('sencho_dev_build_available_digest', expect.anything()); + }); + + it('still gates the registry comparison on a known running image id for an eligible dev-declared pin', async () => { + mockGetPinInfo.mockResolvedValue(DEV_PIN); + mockGetIdentity.mockReturnValue({ + containerId: null, containerName: null, composeProjectName: null, + imageId: null, networkNames: [], volumeNames: [], + }); + + await runEvaluate(); + + expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled(); + expect(devBuildCalls()).toHaveLength(0); + // Unknown running id takes the retry-sooner path, not the detector path. + expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000); + }); }); // ── Per-container parallel fan-out ──────────────────────────────────── diff --git a/backend/src/__tests__/networking-summary.test.ts b/backend/src/__tests__/networking-summary.test.ts index b774b16b..f10d8c99 100644 --- a/backend/src/__tests__/networking-summary.test.ts +++ b/backend/src/__tests__/networking-summary.test.ts @@ -196,7 +196,7 @@ describe('networking summary', () => { it('degrades a remote that errors to a node-error while keeping the hub', async () => { const db = DatabaseService.getInstance(); const remoteId = db.addNode({ name: 'remote-degrade', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); - vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not found', { status: 404 })); try { const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader); diff --git a/backend/src/__tests__/node-management-hardening.test.ts b/backend/src/__tests__/node-management-hardening.test.ts index 71b1faef..ef8fcfc1 100644 --- a/backend/src/__tests__/node-management-hardening.test.ts +++ b/backend/src/__tests__/node-management-hardening.test.ts @@ -19,6 +19,7 @@ import { WebSocket } from 'ws'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; import { PilotTunnelManager } from '../services/PilotTunnelManager'; import { MeshProxyTunnelDialer } from '../services/MeshProxyTunnelDialer'; +import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets'; let tmpDir: string; let app: import('express').Express; @@ -146,6 +147,45 @@ describe('node-management write routes require node:manage', () => { expect(res.status).toBe(200); expect(db.getNode(id)).toBeUndefined(); }); + + it('rejects proxy nodes whose API URL resolves to an unsafe address', async () => { + const res = await request(app) + .post('/api/nodes') + .set('Authorization', `Bearer ${tokenForRole('admin')}`) + .send({ + name: `nm-unsafe-${Date.now()}`, + type: 'remote', + mode: 'proxy', + api_url: 'http://[::ffff:127.0.0.1]:1852', + api_token: 'test-token', + }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not allowed/i); + }); + + it('rejects updating a proxy node to an unsafe API URL', async () => { + const db = DatabaseService.getInstance(); + const id = db.addNode({ + name: `nm-update-unsafe-${Date.now()}`, + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp/x', + is_default: false, + api_url: 'https://remote.example.com:1852', + api_token: 'test-token', + }); + + const res = await withLoopbackTargetProtection(() => request(app) + .put(`/api/nodes/${id}`) + .set('Authorization', `Bearer ${tokenForRole('admin')}`) + .send({ api_url: 'http://127.0.0.1:1852' })); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not allowed/i); + expect(db.getNode(id)?.api_url).toBe('https://remote.example.com:1852'); + db.deleteNode(id); + }); }); describe('deleting a node tears down its tunnel or mesh bridge', () => { diff --git a/backend/src/__tests__/node-registry-fetch-meta.test.ts b/backend/src/__tests__/node-registry-fetch-meta.test.ts index dd8f5d02..d7a38979 100644 --- a/backend/src/__tests__/node-registry-fetch-meta.test.ts +++ b/backend/src/__tests__/node-registry-fetch-meta.test.ts @@ -77,6 +77,7 @@ describe('NodeRegistry.fetchMetaForNode', () => { vi.spyOn(reg, 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:54321', apiToken: '', + trustedLoopback: true, }); const axiosSpy = vi.spyOn(axios, 'get').mockResolvedValue({ data: { @@ -96,8 +97,10 @@ describe('NodeRegistry.fetchMetaForNode', () => { expect(axiosSpy).toHaveBeenCalledTimes(1); const url = axiosSpy.mock.calls[0][0]; expect(url).toBe('http://127.0.0.1:54321/api/meta'); - const init = axiosSpy.mock.calls[0][1] as { headers: Record }; + const init = axiosSpy.mock.calls[0][1] as { headers: Record; httpAgent?: unknown; httpsAgent?: unknown }; expect(init.headers).toEqual({}); + expect(init.httpAgent).toBeUndefined(); + expect(init.httpsAgent).toBeUndefined(); db.deleteNode(nodeId); }); @@ -118,6 +121,7 @@ describe('NodeRegistry.fetchMetaForNode', () => { vi.spyOn(reg, 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'real-token', + trustedLoopback: false, }); const axiosSpy = vi.spyOn(axios, 'get').mockResolvedValue({ data: { version: '0.76.7', capabilities: [], startedAt: 1, updateError: null }, @@ -125,8 +129,10 @@ describe('NodeRegistry.fetchMetaForNode', () => { await reg.fetchMetaForNode(nodeId); - const init = axiosSpy.mock.calls[0][1] as { headers: Record }; + const init = axiosSpy.mock.calls[0][1] as { headers: Record; httpAgent?: unknown; httpsAgent?: unknown }; expect(init.headers).toEqual({ Authorization: 'Bearer real-token' }); + expect(init.httpAgent).toBeDefined(); + expect(init.httpsAgent).toBeDefined(); db.deleteNode(nodeId); }); diff --git a/backend/src/__tests__/nodes.test.ts b/backend/src/__tests__/nodes.test.ts index f22d46f2..10d0cd7f 100644 --- a/backend/src/__tests__/nodes.test.ts +++ b/backend/src/__tests__/nodes.test.ts @@ -59,7 +59,7 @@ describe('POST /api/nodes - api_url SSRF validation (C2 fix)', () => { .set('Authorization', authHeader) .send({ name: 'bad-node', type: 'remote', api_url: 'http://localhost:6379' }); expect(res.status).toBe(400); - expect(res.body.error).toMatch(/loopback/i); + expect(res.body.error).toMatch(/not allowed/i); }); it('rejects 127.0.0.1 api_url', async () => { diff --git a/backend/src/__tests__/outbound-target.test.ts b/backend/src/__tests__/outbound-target.test.ts new file mode 100644 index 00000000..3fd8cd9f --- /dev/null +++ b/backend/src/__tests__/outbound-target.test.ts @@ -0,0 +1,183 @@ +import http from 'http'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + assertSafeOutboundUrl, + isBlockedOutboundAddress, + safeOutboundLookup, + safeAxiosTransport, + safeRemoteFetch, + UnsafeOutboundTargetError, +} from '../utils/outboundTarget'; +import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('outbound target validation', () => { + it.each([ + '127.0.0.1', + '169.254.169.254', + '100.100.100.200', + '192.0.2.10', + '198.18.0.10', + '198.51.100.10', + '203.0.113.10', + '0.0.0.0', + '224.0.0.1', + '::1', + 'fe80::1', + 'ff02::1', + '100::1', + '2001:db8::1', + 'fd00:ec2::254', + '::ffff:7f00:1', + ])('blocks unsafe address %s', (address) => { + expect(isBlockedOutboundAddress(address)).toBe(true); + }); + + it.each([ + '10.0.0.10', + '172.16.0.10', + '192.168.1.10', + 'fd12:3456:789a::10', + '8.8.8.8', + '::ffff:808:808', + ])('allows private and ordinary unicast address %s', (address) => { + expect(isBlockedOutboundAddress(address)).toBe(false); + }); + + it('rejects a URL whose literal host is unsafe', async () => { + await expect(withLoopbackTargetProtection(() => + assertSafeOutboundUrl('https://[::ffff:127.0.0.1]/repo.git'))) + .rejects.toBeInstanceOf(UnsafeOutboundTargetError); + }); + + it('accepts private LAN targets', async () => { + await expect(assertSafeOutboundUrl('http://192.168.1.50:1852')) + .resolves.toMatchObject({ hostname: '192.168.1.50' }); + }); + + it('allows only loopback targets when the E2E test override is active', async () => { + const actual = await vi.importActual('../utils/outboundTarget'); + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('SENCHO_E2E_ALLOW_LOOPBACK_OUTBOUND', 'true'); + + await expect(actual.resolveSafeOutboundHostname('127.0.0.1')) + .resolves.toEqual([{ address: '127.0.0.1', family: 4 }]); + await expect(actual.resolveSafeOutboundHostname('169.254.169.254')) + .rejects.toMatchObject({ reason: 'blocked' }); + }); + + it('ignores the E2E loopback override outside the test environment', async () => { + const actual = await vi.importActual('../utils/outboundTarget'); + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('SENCHO_E2E_ALLOW_LOOPBACK_OUTBOUND', 'true'); + + await expect(actual.resolveSafeOutboundHostname('127.0.0.1')) + .rejects.toMatchObject({ reason: 'blocked' }); + }); + + it('rejects an unsafe address inside the connection lookup', async () => { + const error = await withLoopbackTargetProtection(() => + new Promise((resolve) => { + safeOutboundLookup('127.0.0.1', {}, (lookupError) => resolve(lookupError)); + })); + + expect(error).toBeInstanceOf(UnsafeOutboundTargetError); + }); + + it('rejects a hostname when any resolved address is unsafe', async () => { + const actual = await vi.importActual('../utils/outboundTarget'); + const resolveMixed = async () => [ + { address: '93.184.216.34', family: 4 as const }, + { address: '169.254.169.254', family: 4 as const }, + ]; + + await expect(actual.resolveSafeOutboundHostname('mixed.example', resolveMixed)) + .rejects.toMatchObject({ reason: 'blocked' }); + }); + + it('rejects an exact metadata address returned by DNS', async () => { + const actual = await vi.importActual('../utils/outboundTarget'); + const resolveMetadata = async () => [ + { address: '100.100.100.200', family: 4 as const }, + ]; + + await expect(actual.resolveSafeOutboundHostname('metadata.example', resolveMetadata)) + .rejects.toMatchObject({ reason: 'blocked' }); + }); + + it('rechecks DNS at connection time after safe validation', async () => { + const actual = await vi.importActual('../utils/outboundTarget'); + const resolveSafe = async () => [{ address: '93.184.216.34', family: 4 as const }]; + await expect(actual.resolveSafeOutboundHostname('rebinding.example', resolveSafe)).resolves.toHaveLength(1); + + const rebindingLookup = actual.createSafeOutboundLookup((_hostname, _options, callback) => { + callback(null, [{ address: '169.254.169.254', family: 4 }]); + }); + const connectionError = await new Promise((resolve) => { + rebindingLookup('rebinding.example', {}, (lookupError) => resolve(lookupError)); + }); + expect(connectionError).toMatchObject({ reason: 'blocked' }); + }); + + it('rejects unsafe addresses at fetch connection time', async () => { + await expect(withLoopbackTargetProtection(() => + safeRemoteFetch('http://127.0.0.1:1852/api/meta'))) + .rejects.toBeInstanceOf(UnsafeOutboundTargetError); + }); + + it('rejects redirects without contacting the redirect destination', async () => { + const actual = await vi.importActual('../utils/outboundTarget'); + let destinationRequests = 0; + const destination = http.createServer((_req, res) => { + destinationRequests += 1; + res.end('unexpected'); + }); + await new Promise((resolve) => destination.listen(0, '127.0.0.1', resolve)); + const destinationAddress = destination.address(); + if (!destinationAddress || typeof destinationAddress === 'string') throw new Error('Missing destination address'); + + const redirect = http.createServer((_req, res) => { + res.writeHead(302, { Location: `http://127.0.0.1:${destinationAddress.port}/target` }); + res.end(); + }); + await new Promise((resolve) => redirect.listen(0, '127.0.0.1', resolve)); + const redirectAddress = redirect.address(); + if (!redirectAddress || typeof redirectAddress === 'string') throw new Error('Missing redirect address'); + + try { + await expect(actual.safeRemoteFetch( + `http://127.0.0.1:${redirectAddress.port}/start`, + {}, + true, + )).rejects.toThrow(); + expect(destinationRequests).toBe(0); + } finally { + await Promise.all([ + new Promise((resolve, reject) => destination.close((error) => error ? reject(error) : resolve())), + new Promise((resolve, reject) => redirect.close((error) => error ? reject(error) : resolve())), + ]); + } + }); + + it('isolates protected loopback checks from concurrent fixture requests', async () => { + const [ordinary, protectedResult] = await Promise.all([ + assertSafeOutboundUrl('http://127.0.0.1:1852').then(() => 'allowed'), + withLoopbackTargetProtection(() => assertSafeOutboundUrl('http://127.0.0.1:1852')) + .then(() => 'allowed', () => 'blocked'), + ]); + + expect(ordinary).toBe('allowed'); + expect(protectedResult).toBe('blocked'); + }); + + it('disables environment proxy routing for guarded Axios requests', () => { + expect(safeAxiosTransport(false)).toMatchObject({ + maxRedirects: 0, + proxy: false, + }); + }); +}); diff --git a/backend/src/__tests__/pilot-enrollment.test.ts b/backend/src/__tests__/pilot-enrollment.test.ts index 4d6e8f6c..5a15d379 100644 --- a/backend/src/__tests__/pilot-enrollment.test.ts +++ b/backend/src/__tests__/pilot-enrollment.test.ts @@ -18,6 +18,7 @@ import request from 'supertest'; import crypto from 'crypto'; import { parse as parseYaml } from 'yaml'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import { resetTrustedProxyBlockListCache } from '../helpers/trustedProxyCidrs'; interface ComposeService { image: string; @@ -223,6 +224,39 @@ describe('SENCHO_PUBLIC_URL override in mintPilotEnrollment', () => { expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); }); + it('ignores a forwarded HTTPS scheme from an untrusted peer', async () => { + delete process.env.SENCHO_PUBLIC_URL; + const res = await request(app) + .post('/api/nodes') + .set('Cookie', adminCookie) + .set('Host', 'sencho.example.com') + .set('X-Forwarded-Proto', 'https') + .send({ name: 'pilot-untrusted-forwarded-scheme', type: 'remote', mode: 'pilot_agent' }); + + const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile; + expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toBe('http://sencho.example.com'); + }); + + it('honors a forwarded HTTPS scheme from an allowlisted proxy peer', async () => { + delete process.env.SENCHO_PUBLIC_URL; + process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8'; + resetTrustedProxyBlockListCache(); + try { + const res = await request(app) + .post('/api/nodes') + .set('Cookie', adminCookie) + .set('Host', 'sencho.example.com') + .set('X-Forwarded-Proto', 'https') + .send({ name: 'pilot-trusted-forwarded-scheme', type: 'remote', mode: 'pilot_agent' }); + + const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile; + expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toBe('https://sencho.example.com'); + } finally { + delete process.env.SENCHO_TRUSTED_PROXY_CIDRS; + resetTrustedProxyBlockListCache(); + } + }); + it('falls back to request host when env var is malformed', async () => { process.env.SENCHO_PUBLIC_URL = 'not a url'; const res = await request(app) diff --git a/backend/src/__tests__/policy-enforcement.test.ts b/backend/src/__tests__/policy-enforcement.test.ts index c3ec63fe..6e2fb947 100644 --- a/backend/src/__tests__/policy-enforcement.test.ts +++ b/backend/src/__tests__/policy-enforcement.test.ts @@ -69,6 +69,7 @@ import { _resetTrivyMissingNotificationStateForTests, enforcePolicyForImageRefs, enforcePolicyPreDeploy, + evaluateCandidatePolicy, } from '../services/PolicyEnforcement'; function mkPolicy(overrides: Partial = {}): ScanPolicy { @@ -796,3 +797,129 @@ describe('enforcePolicyForImageRefs - risk-based inputs (KEV / fixable / optiona expect(dbStub.insertAuditLog.mock.calls[0][0].summary).toContain('policy.suppression_pass'); }); }); + +// Candidate (pre-acceptance) evaluation must not fail open the way the +// deploy-time gate deliberately does: an unresolvable scanner state must +// surface as its own `unavailable` outcome so a caller can withhold automatic +// acceptance, rather than silently reusing `ok: true`. +describe('evaluateCandidatePolicy', () => { + beforeEach(() => { + trivyStub.isTrivyAvailable.mockReset(); + trivyStub.scanImagePreflight.mockReset(); + composeStub.listStackImages.mockReset(); + dbStub.getMatchingPolicy.mockReset(); + dbStub.insertAuditLog.mockReset(); + dbStub.getGlobalSettings.mockReset().mockReturnValue({}); + dbStub.getAllVulnerabilityDetails.mockReset().mockReturnValue([]); + dbStub.getCveSuppressions.mockReset().mockReturnValue([]); + dbStub.getCveIntel.mockReset().mockReturnValue(new Map()); + notificationStub.dispatchAlert.mockReset(); + _resetTrivyMissingNotificationStateForTests(); + }); + + it('reports allowed when no matching policy exists', async () => { + dbStub.getMatchingPolicy.mockReturnValue(null); + + const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: false, actor: 'u' }); + + expect(result.status).toBe('allowed'); + }); + + it('reports unavailable, not allowed, when the scanner cannot be evaluated', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(false); + + const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: false, actor: 'u' }); + + expect(result.status).toBe('unavailable'); + if (result.status === 'unavailable') { + expect(result.reason).toBeTruthy(); + } + }); + + it('reports blocked when a scanned image exceeds the policy severity', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(true); + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 7, highest_severity: 'CRITICAL', critical_count: 1 })); + + const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: false, actor: 'u' }); + + expect(result.status).toBe('blocked'); + if (result.status === 'blocked') { + expect(result.violations).toHaveLength(1); + } + }); + + it('reports allowed on an authorized bypass', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(true); + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 8, highest_severity: 'CRITICAL', critical_count: 1 })); + + const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: true, actor: 'admin' }); + + expect(result.status).toBe('allowed'); + }); + + it('never reads compose from disk; the caller supplies candidate image refs', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(true); + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 9, highest_severity: 'LOW' })); + + await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: false, actor: 'u' }); + + expect(composeStub.listStackImages).not.toHaveBeenCalled(); + }); + + it('reports unavailable, not allowed, for an image reference that cannot be scanned', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(true); + + const result = await evaluateCandidatePolicy('web', 1, ['not a valid ref!!'], { bypass: false, actor: 'u' }); + + expect(result.status).toBe('unavailable'); + expect(trivyStub.scanImagePreflight).not.toHaveBeenCalled(); + }); + + it('reports unavailable, not blocked, when the scanner throws for every image', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(true); + trivyStub.scanImagePreflight.mockRejectedValue(new Error('scan process crashed')); + + const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: false, actor: 'u' }); + + expect(result.status).toBe('unavailable'); + }); + + it('still reports blocked when at least one image has a genuine scanned violation', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(true); + trivyStub.scanImagePreflight + .mockRejectedValueOnce(new Error('scan process crashed')) + .mockResolvedValueOnce(mkScan({ id: 11, highest_severity: 'CRITICAL', critical_count: 1 })); + + const result = await evaluateCandidatePolicy('web', 1, ['unreachable:1', 'nginx:1.27'], { bypass: false, actor: 'u' }); + + expect(result.status).toBe('blocked'); + }); + + it('honors an explicit bypass even when the scanner is unavailable', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(false); + + const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: true, actor: 'admin' }); + + expect(result.status).toBe('allowed'); + }); + + it('does not attribute its audit trail to a deploy that never happened', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + trivyStub.isTrivyAvailable.mockReturnValue(true); + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 10, highest_severity: 'CRITICAL', critical_count: 1 })); + + await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: true, actor: 'admin' }); + + expect(dbStub.insertAuditLog).toHaveBeenCalledTimes(1); + const entry = dbStub.insertAuditLog.mock.calls[0][0]; + expect(entry.path).not.toMatch(/\/deploy$/); + }); +}); diff --git a/backend/src/__tests__/proxy-mount-order.test.ts b/backend/src/__tests__/proxy-mount-order.test.ts index bbbbed9d..3f8fb41e 100644 --- a/backend/src/__tests__/proxy-mount-order.test.ts +++ b/backend/src/__tests__/proxy-mount-order.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets'; describe('remote proxy mount order', () => { let tmpDir: string; @@ -65,6 +66,16 @@ describe('remote proxy mount order', () => { expect(res.body?.error).toMatch(/unreachable/i); }); + it('refuses an unsafe target from an existing node row', async () => { + const res = await withLoopbackTargetProtection(() => request(app) + .get('/api/stacks') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId))); + + expect(res.status).toBe(502); + expect(res.body?.error).toMatch(/not allowed/i); + }); + it('routes local requests (no x-node-id) to the local handler', async () => { const res = await request(app) .get('/api/stacks') diff --git a/backend/src/__tests__/proxy-pilot-agent-role-header.test.ts b/backend/src/__tests__/proxy-pilot-agent-role-header.test.ts index 2e246548..fe3310e4 100644 --- a/backend/src/__tests__/proxy-pilot-agent-role-header.test.ts +++ b/backend/src/__tests__/proxy-pilot-agent-role-header.test.ts @@ -57,7 +57,7 @@ describe('pilot-agent-mode proxy role header parity', () => { const registry = NodeRegistry.getInstance(); const orig = registry.getProxyTarget.bind(registry); vi.spyOn(registry, 'getProxyTarget').mockImplementation((nid: number) => { - if (nid === pilotNodeId) return { apiUrl: `http://127.0.0.1:${port}`, apiToken: '' }; + if (nid === pilotNodeId) return { apiUrl: `http://127.0.0.1:${port}`, apiToken: '', trustedLoopback: true }; return orig(nid); }); }); diff --git a/backend/src/__tests__/recovery-captured-invocation.test.ts b/backend/src/__tests__/recovery-captured-invocation.test.ts index d6f2f10f..11efa538 100644 --- a/backend/src/__tests__/recovery-captured-invocation.test.ts +++ b/backend/src/__tests__/recovery-captured-invocation.test.ts @@ -48,6 +48,7 @@ describe('captured invocation on recovery Compose args', () => { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: 'abc', diff --git a/backend/src/__tests__/registry-delivery-outbound.test.ts b/backend/src/__tests__/registry-delivery-outbound.test.ts index bbcefeaf..6052d763 100644 --- a/backend/src/__tests__/registry-delivery-outbound.test.ts +++ b/backend/src/__tests__/registry-delivery-outbound.test.ts @@ -66,7 +66,7 @@ describe('registryDeliveryOutbound', () => { apiPath: '/api/stacks/demo/deploy', nodeId, node, - target: { apiUrl: 'http://remote:1852', apiToken: 'token' }, + target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false }, body, }); @@ -86,7 +86,7 @@ describe('registryDeliveryOutbound', () => { apiPath: '/api/stacks/demo/deploy', nodeId, node, - target: { apiUrl: 'http://remote:1852', apiToken: 'token' }, + target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false }, body, }); @@ -122,7 +122,7 @@ describe('registryDeliveryOutbound', () => { apiPath: '/api/stacks/demo/deploy', nodeId, node, - target: { apiUrl: 'http://remote:1852', apiToken: 'token' }, + target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false }, body: {}, abortSignal: controller.signal, }); @@ -160,7 +160,7 @@ describe('registryDeliveryOutbound', () => { apiPath: '/api/stacks/demo/deploy', nodeId, node, - target: { apiUrl: 'http://remote:1852', apiToken: 'token' }, + target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false }, body: {}, }); @@ -210,7 +210,7 @@ describe('registryDeliveryOutbound', () => { apiPath: '/api/stacks/demo/deploy', nodeId, node, - target: { apiUrl: 'http://remote:1852', apiToken: 'token' }, + target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false }, body: {}, abortSignal: controller.signal, }); diff --git a/backend/src/__tests__/remote-forwarder-target.test.ts b/backend/src/__tests__/remote-forwarder-target.test.ts new file mode 100644 index 00000000..07d8b7d4 --- /dev/null +++ b/backend/src/__tests__/remote-forwarder-target.test.ts @@ -0,0 +1,37 @@ +import { IncomingMessage } from 'http'; +import { Socket } from 'net'; +import { PassThrough } from 'stream'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { wsProxyServer } from '../proxy/websocketProxy'; +import { handleRemoteForwarder } from '../websocket/remoteForwarder'; +import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets'; + +afterEach(() => vi.restoreAllMocks()); + +describe('remote WebSocket target validation', () => { + it('rejects an unsafe target before invoking the WebSocket proxy', async () => { + const req = new IncomingMessage(new Socket()); + req.url = '/api/containers/demo/logs?nodeId=2'; + req.headers.host = 'sencho.example'; + const socket = new PassThrough(); + socket.resume(); + const proxySpy = vi.spyOn(wsProxyServer, 'ws').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await withLoopbackTargetProtection(() => handleRemoteForwarder( + req, + socket, + Buffer.alloc(0), + { + pathname: '/api/containers/demo/logs', + target: { + apiUrl: 'http://127.0.0.1:1852', + apiToken: 'test-token', + trustedLoopback: false, + }, + }, + )); + + expect(proxySpy).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/__tests__/self-identity-buildinfo.test.ts b/backend/src/__tests__/self-identity-buildinfo.test.ts new file mode 100644 index 00000000..e5445e5b --- /dev/null +++ b/backend/src/__tests__/self-identity-buildinfo.test.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for SelfIdentityService.getBuildInfo(): the canonical runtime + * build identity (version, channel, imageRef, imageId, revision), the detached + * bounded revision enrichment, and the failure-isolation guarantee. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── Hoisted mocks ────────────────────────────────────────────────────── + +const { mockContainer, mockDocker, mockInspectImage, mockGetSenchoVersion } = vi.hoisted(() => { + const mockContainer = { inspect: vi.fn() }; + const mockDocker = { + getContainer: vi.fn(() => mockContainer), + getImage: vi.fn(), + listImages: vi.fn().mockResolvedValue([]), + listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }), + listNetworks: vi.fn().mockResolvedValue([]), + listContainers: vi.fn().mockResolvedValue([]), + }; + return { + mockContainer, + mockDocker, + mockInspectImage: vi.fn(), + mockGetSenchoVersion: vi.fn(() => '0.97.1'), + }; +}); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getDocker: () => mockDocker, + getDefaultNodeId: () => 1, + }), + }, +})); + +// Replace defaultInspectImage so enrichment is deterministic and isolated. +vi.mock('../services/selfDevBuildDetect', () => ({ + defaultInspectImage: (...args: unknown[]) => mockInspectImage(...args), +})); + +vi.mock('../services/CapabilityRegistry', () => ({ + getSenchoVersion: () => mockGetSenchoVersion(), +})); + +vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn() })); +vi.mock('util', () => ({ promisify: () => vi.fn() })); + +import SelfIdentityService from '../services/SelfIdentityService'; + +const FULL_IMAGE_ID_HEX = 'b'.repeat(64); +const DIGEST = 'a'.repeat(64); + +const originalHostname = process.env.HOSTNAME; + +beforeEach(() => { + vi.clearAllMocks(); + mockContainer.inspect.mockReset(); + mockDocker.getImage.mockReset(); + mockInspectImage.mockReset(); + mockGetSenchoVersion.mockReturnValue('0.97.1'); + SelfIdentityService.getInstance().resetForTesting(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (originalHostname === undefined) delete process.env.HOSTNAME; + else process.env.HOSTNAME = originalHostname; +}); + +async function initWith(configImage: string | undefined): Promise { + process.env.HOSTNAME = 'sencho-1'; + mockContainer.inspect.mockResolvedValue({ + Id: 'a'.repeat(64), + Name: '/sencho', + Image: 'sha256:' + FULL_IMAGE_ID_HEX, + ...(configImage !== undefined ? { Config: { Image: configImage } } : {}), + NetworkSettings: { Networks: {} }, + Mounts: [], + }); + const svc = SelfIdentityService.getInstance(); + await svc.initialize(); + return svc; +} + +/** Enrichment runs detached; poll until the condition holds so assertions are stable. */ +async function until(assert: () => void): Promise { + await vi.waitFor(assert, { timeout: 2000 }); +} + +describe('SelfIdentityService.getBuildInfo', () => { + it('identifies a dev image as DEV even when the packaged semver matches the previous stable', async () => { + // The regression case from the ticket: dev image, version still 0.97.1. + // The inspect mock must be set before initialize(): enrichment fires + // detached during initialize(), before the awaited call returns. + mockInspectImage.mockResolvedValue({ + RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`], + Os: 'linux', + Architecture: 'amd64', + }); + const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev'); + await until(() => expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`)); + + const info = svc.getBuildInfo(); + expect(info.version).toBe('0.97.1'); + expect(info.channel).toBe('dev'); + expect(info.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev'); + expect(info.imageId).toBe(FULL_IMAGE_ID_HEX); + }); + + it('derives the revision from a pinned dev- tag without an image inspect', async () => { + const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev-abc1234'); + await until(() => expect(svc.getBuildInfo().revision).toBe('dev-abc1234')); + + const info = svc.getBuildInfo(); + expect(info.channel).toBe('dev'); + expect(mockInspectImage).not.toHaveBeenCalled(); + }); + + it('classifies a stable image as stable', async () => { + mockInspectImage.mockResolvedValue({ + RepoDigests: [`ghcr.io/studio-saelix/sencho@sha256:${DIGEST}`], + Os: 'linux', + Architecture: 'amd64', + }); + const svc = await initWith('ghcr.io/studio-saelix/sencho:0.97.1'); + await until(() => expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`)); + + expect(svc.getBuildInfo().channel).toBe('stable'); + }); + + it('reads unknown for partial metadata (no running image reference)', async () => { + const svc = await initWith(undefined); + + const info = svc.getBuildInfo(); + expect(info.imageRef).toBeNull(); + expect(info.channel).toBe('unknown'); + expect(info.revision).toBeNull(); + expect(info.imageId).toBe(FULL_IMAGE_ID_HEX); + }); + + it('keeps the dev channel but null revision when image inspection fails', async () => { + mockInspectImage.mockRejectedValue(new Error('docker daemon unreachable')); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev'); + await until(() => expect(warnSpy).toHaveBeenCalled()); + + const info = svc.getBuildInfo(); + expect(info.channel).toBe('dev'); + expect(info.revision).toBeNull(); + // C2: a failed enrichment must not corrupt the already-captured core identity. + expect(info.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev'); + expect(info.imageId).toBe(FULL_IMAGE_ID_HEX); + }); + + it('never inspects the image again on repeated reads', async () => { + mockInspectImage.mockResolvedValue({ + RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`], + Os: 'linux', + Architecture: 'amd64', + }); + const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev'); + await until(() => expect(mockInspectImage).toHaveBeenCalledTimes(1)); + + for (let i = 0; i < 5; i++) svc.getBuildInfo(); + expect(mockInspectImage).toHaveBeenCalledTimes(1); + }); + + it('exposes revision only after enrichment settles, and whenRevisionResolved awaits that', async () => { + let resolveInspect!: (v: { RepoDigests: string[]; Os: string; Architecture: string }) => void; + mockInspectImage.mockReturnValue(new Promise((res) => { resolveInspect = res; })); + + const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev'); + // initialize() returned without awaiting the detached enrichment, so the + // revision is still transiently null and the settle promise is pending. + expect(svc.getBuildInfo().revision).toBeNull(); + + // A reader that awaits the settle promise (the build-info route) blocks + // until enrichment lands, then observes the resolved digest, so a single + // successful read never freezes a transient null. + const settled = svc.whenRevisionResolved(); + resolveInspect({ + RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`], + Os: 'linux', + Architecture: 'amd64', + }); + await settled; + expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`); + }); + + it('resolves whenRevisionResolved immediately when no enrichment ever started', async () => { + process.env.HOSTNAME = undefined; + const svc = SelfIdentityService.getInstance(); + await svc.whenRevisionResolved(); + expect(svc.getBuildInfo().revision).toBeNull(); + }); +}); diff --git a/backend/src/__tests__/source-controller.test.ts b/backend/src/__tests__/source-controller.test.ts new file mode 100644 index 00000000..1d9b204e --- /dev/null +++ b/backend/src/__tests__/source-controller.test.ts @@ -0,0 +1,207 @@ +/** + * SourceController: the background driver for unattended reconciliation. + * GitOpsStore's due-queries and GitSourceService.reconcile() are mocked so + * these tests exercise only the timer/coalescing behavior, not real fetch + * or apply mechanics (already covered by git-source-service.test.ts). + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { directApplicationFixture } from './helpers/gitopsFixtures'; +import { GitOpsStore } from '../services/gitops/store'; +import { GitSourceService } from '../services/GitSourceService'; +import { SourceController } from '../services/gitops/SourceController'; +import type { GitOpsApplicationRow } from '../services/gitops/types'; +import type { ReconcileResult } from '../services/gitops/outcomes'; + +const TICK_MS = 60_000; +const okResult: ReconcileResult = { outcome: 'no_source_change', reason: 'ok', nextAction: 'none' }; + +let tmpDir: string; +let controller: SourceController; + +/** Point both due-queries at fixed rows; the scan reads nothing else. */ +function mockDue(duePoll: GitOpsApplicationRow[], dueRetry: GitOpsApplicationRow[] = []): void { + vi.spyOn(GitOpsStore.getInstance(), 'listSourcesDueForPoll').mockReturnValue(duePoll); + vi.spyOn(GitOpsStore.getInstance(), 'listApplicationsDueForRetry').mockReturnValue(dueRetry); +} + +function spyOnReconcile() { + return vi.spyOn(GitSourceService.getInstance(), 'reconcile'); +} + +/** Run the next scheduled tick and let the evaluations it fires settle. */ +async function advanceOneTick(): Promise { + await vi.advanceTimersByTimeAsync(TICK_MS); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + GitOpsStore.resetForTests(); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + vi.useFakeTimers(); + SourceController.resetForTests(); + controller = SourceController.getInstance(); +}); + +afterEach(() => { + controller.stop(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('SourceController', () => { + it('evaluates a source whose poll interval is due', async () => { + mockDue([directApplicationFixture('app-poll', 'poll-web')]); + const reconcile = spyOnReconcile().mockResolvedValue(okResult); + + controller.start(); + await advanceOneTick(); + + expect(reconcile).toHaveBeenCalledWith(expect.objectContaining({ + intent: 'fetch', + applicationId: 'app-poll', + stackName: 'poll-web', + trigger: 'poll', + })); + }); + + it('evaluates an application whose retry_at has arrived, tagged as a retry trigger', async () => { + const app = { ...directApplicationFixture('app-retry', 'retry-web'), retry_at: Date.now() - 1_000 }; + mockDue([], [app]); + const reconcile = spyOnReconcile().mockResolvedValue(okResult); + + controller.start(); + await advanceOneTick(); + + expect(reconcile).toHaveBeenCalledWith(expect.objectContaining({ + applicationId: 'app-retry', + trigger: 'retry', + })); + }); + + it('evaluates an application due for both poll and retry exactly once', async () => { + const app = { ...directApplicationFixture('app-both', 'both-web'), retry_at: Date.now() - 1_000 }; + mockDue([app], [app]); + const reconcile = spyOnReconcile().mockResolvedValue(okResult); + + controller.start(); + await advanceOneTick(); + + expect(reconcile).toHaveBeenCalledTimes(1); + }); + + it('does not re-evaluate an application still in flight from a previous tick', async () => { + mockDue([directApplicationFixture('app-slow', 'slow-web')]); + let settleFirstCall!: (result: ReconcileResult) => void; + const firstCall = new Promise((resolve) => { settleFirstCall = resolve; }); + const reconcile = spyOnReconcile().mockReturnValue(firstCall); + + controller.start(); + await advanceOneTick(); + expect(reconcile).toHaveBeenCalledTimes(1); + + // A second tick fires while the first evaluation is still pending. + await advanceOneTick(); + expect(reconcile).toHaveBeenCalledTimes(1); + + settleFirstCall(okResult); + await Promise.resolve(); + await Promise.resolve(); + + // Now that the first evaluation has settled, a later tick may pick it up again. + await advanceOneTick(); + expect(reconcile).toHaveBeenCalledTimes(2); + }); + + it('recovers on the next tick after a store query throws, rather than dying permanently', async () => { + mockDue([directApplicationFixture('app-recovers', 'recovers-web')]); + vi.spyOn(GitOpsStore.getInstance(), 'listSourcesDueForPoll').mockImplementationOnce(() => { + throw new Error('database is locked'); + }); + const reconcile = spyOnReconcile().mockResolvedValue(okResult); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + controller.start(); + await advanceOneTick(); + expect(reconcile).not.toHaveBeenCalled(); + + await advanceOneTick(); + expect(reconcile).toHaveBeenCalledTimes(1); + }); + + it('releases the in-flight slot for an application whose reconcile rejects', async () => { + mockDue([directApplicationFixture('app-rejects', 'rejects-web')]); + const reconcile = spyOnReconcile().mockRejectedValue(new Error('boom')); + + controller.start(); + await advanceOneTick(); + await advanceOneTick(); + + expect(reconcile).toHaveBeenCalledTimes(2); + }); + + it('does not evaluate anything after stop', async () => { + mockDue([directApplicationFixture('app-stopped', 'stopped-web')]); + const reconcile = spyOnReconcile().mockResolvedValue(okResult); + + controller.start(); + controller.stop(); + await advanceOneTick(); + await advanceOneTick(); + + expect(reconcile).not.toHaveBeenCalled(); + }); + + it('does not double-arm when start() is called reentrantly from within an in-flight evaluation', async () => { + mockDue([directApplicationFixture('app-reentrant-start', 'reentrant-start-web')]); + // tick() nulls `timer` before scanning, so a start() call landing + // synchronously during that scan must not see a false "not running" + // reading and arm a second timer. + spyOnReconcile().mockImplementation(() => { + controller.start(); + return Promise.resolve(okResult); + }); + + controller.start(); + await advanceOneTick(); + + expect(vi.getTimerCount()).toBe(1); + }); + + it('logs rather than silently skipping an application with no stack_name', async () => { + mockDue([{ ...directApplicationFixture('app-no-stack', 'no-stack-web'), stack_name: null }]); + const reconcile = spyOnReconcile().mockResolvedValue(okResult); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + controller.start(); + await advanceOneTick(); + + expect(reconcile).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('app-no-stack')); + }); + + it('restartPolling never leaves two timers running', () => { + controller.start(); + controller.restartPolling(); + controller.restartPolling(); + + expect(vi.getTimerCount()).toBe(1); + }); + + it('start is a no-op when already running', async () => { + mockDue([directApplicationFixture('app-double-start', 'double-start-web')]); + const reconcile = spyOnReconcile().mockResolvedValue(okResult); + + controller.start(); + controller.start(); + await advanceOneTick(); + + expect(reconcile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/__tests__/ssh-trust.test.ts b/backend/src/__tests__/ssh-trust.test.ts index 593051d4..7791e3b2 100644 --- a/backend/src/__tests__/ssh-trust.test.ts +++ b/backend/src/__tests__/ssh-trust.test.ts @@ -31,6 +31,12 @@ describe('sshTrust URL parsing', () => { expect(parsed?.port).toBe(2222); }); + it('preserves absolute paths in default-port ssh:// URLs', () => { + const parsed = parseSshUrl('ssh://git@host.example/abs/path/repo.git'); + expect(parsed?.href).toBe('git@host.example:/abs/path/repo.git'); + expect(parsed?.pathname).toBe('/abs/path/repo.git'); + }); + it('classifies transport kind from mixed inputs', () => { expect(parseRepoTransportUrl('https://github.com/org/repo.git')?.kind).toBe('https'); expect(parseRepoTransportUrl('git@host:org/repo.git')?.kind).toBe('ssh'); @@ -72,11 +78,23 @@ describe('ssh credential canonicalization', () => { describe('ssh command builder', () => { it('enforces strict host key checking', () => { - const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts'); + const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts', { + address: '10.0.0.8', + hostKeyAlias: 'git.internal.example', + }); expect(cmd).toContain('StrictHostKeyChecking=yes'); expect(cmd).toContain('UserKnownHostsFile=/tmp/known_hosts'); expect(cmd).toContain('IdentitiesOnly=yes'); }); + + it('pins the address while retaining the repository host identity', () => { + const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts', { + address: '10.0.0.8', + hostKeyAlias: '[git.internal.example]:2222', + }); + expect(cmd).toContain('Hostname=10.0.0.8'); + expect(cmd).toContain('HostKeyAlias=[git.internal.example]:2222'); + }); }); describe('SSH stderr classification', () => { diff --git a/backend/src/__tests__/stackRouteAuth.test.ts b/backend/src/__tests__/stackRouteAuth.test.ts index c6efe976..03565c5c 100644 --- a/backend/src/__tests__/stackRouteAuth.test.ts +++ b/backend/src/__tests__/stackRouteAuth.test.ts @@ -79,6 +79,21 @@ describe('classifyStackApiPath', () => { kind: 'named-stack', stackName: 'web', action: 'stack:edit', }); }); + + // Load-bearing the same way as history/manifest above: without a rule + // here, suspend/resume/retry 403 on every remote node before the + // controller routes that use them exist. + it('maps git-source/suspend, resume, and retry to stack:edit', () => { + expect(classifyStackApiPath('POST', '/stacks/web/git-source/suspend')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('POST', '/stacks/web/git-source/resume')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('POST', '/stacks/web/git-source/retry')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + }); }); describe('static exclusions', () => { diff --git a/backend/src/__tests__/testHandleResolver.test.ts b/backend/src/__tests__/testHandleResolver.test.ts new file mode 100644 index 00000000..9a524d91 --- /dev/null +++ b/backend/src/__tests__/testHandleResolver.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTestHandle } from './__helpers__/testHandleResolver'; + +const FILE = 'fixture.test.ts'; + +describe('resolveTestHandle', () => { + it('resolves a plain it() declaration', () => { + const src = `it('does the thing', () => { expect(1).toBe(1); });`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true }); + }); + + it('resolves a plain test() declaration', () => { + const src = `test('does the thing', () => {});`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true }); + }); + + it('fails when the title is not present', () => { + const src = `it('does something else', () => {});`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'not-found' }); + }); + + it('fails on a duplicate title with two runnable declarations', () => { + const src = ` + it('does the thing', () => {}); + it('does the thing', () => {}); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'duplicate' }); + }); + + it('fails when the test itself is .skip', () => { + const src = `it.skip('does the thing', () => {});`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' }); + }); + + it('fails when the test itself is .todo', () => { + const src = `it.todo('does the thing');`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' }); + }); + + it('fails when the test itself is .failing', () => { + const src = `it.failing('does the thing', () => {});`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' }); + }); + + it('fails when the test itself is .skipIf', () => { + const src = `it.skipIf(true)('does the thing', () => {});`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' }); + }); + + it('fails under an unconditionally skipped describe', () => { + const src = ` + describe.skip('suite', () => { + it('does the thing', () => {}); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' }); + }); + + it('fails under a describe.runIf, approved-looking predicate or not', () => { + const src = ` + describe.runIf(requireGitBinary())('suite', () => { + it('does the thing', () => {}); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' }); + }); + + it('fails under a describe.skipIf with an arbitrary, unapproved predicate', () => { + const src = ` + describe.skipIf(!someLocalCheck())('suite', () => { + it('does the thing', () => {}); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' }); + }); + + it('passes under a describe.skipIf that calls the approved requireGitBinary helper', () => { + const src = ` + describe.skipIf(!requireGitBinary())('suite', () => { + it('does the thing', () => {}); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true }); + }); + + it('passes under a describe.skipIf that calls the approved requireSshd helper alongside other checks', () => { + const src = ` + describe.skipIf(!requireGitBinary() || !requireSshd())('suite', () => { + it('does the thing', () => {}); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true }); + }); + + it('fails when an approved outer describe.skipIf wraps an unapproved inner describe.skip', () => { + const src = ` + describe.skipIf(!requireGitBinary())('outer', () => { + describe.skip('inner', () => { + it('does the thing', () => {}); + }); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' }); + }); + + it('resolves a title nested two levels inside approved describe.skipIf blocks', () => { + const src = ` + describe.skipIf(!requireGitBinary())('outer', () => { + describe('inner (no modifier)', () => { + it('does the thing', () => {}); + }); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true }); + }); + + it('does not resolve a title only present in a comment', () => { + const src = ` + // it('does the thing', () => {}); + it('does another thing', () => {}); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'not-found' }); + }); +}); diff --git a/backend/src/__tests__/trust-proxy-app.test.ts b/backend/src/__tests__/trust-proxy-app.test.ts new file mode 100644 index 00000000..ea54c11a --- /dev/null +++ b/backend/src/__tests__/trust-proxy-app.test.ts @@ -0,0 +1,68 @@ +import request from 'supertest'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { createApp } from '../app'; +import { isSecureRequest } from '../helpers/cookies'; +import { resetTrustedProxyBlockListCache } from '../helpers/trustedProxyCidrs'; + +describe('Express trusted proxy configuration', () => { + beforeEach(() => { + delete process.env.SENCHO_TRUSTED_PROXY_CIDRS; + resetTrustedProxyBlockListCache(); + }); + + it('ignores forwarded client addresses from an untrusted direct peer', async () => { + const app = createApp(); + app.get('/peer-ip', (req, res) => res.json({ ip: req.ip })); + + const res = await request(app) + .get('/peer-ip') + .set('X-Forwarded-For', '203.0.113.50'); + + expect(res.status).toBe(200); + expect(res.body.ip).not.toBe('203.0.113.50'); + }); + + it('honors forwarded client addresses from an allowlisted proxy peer', async () => { + process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8'; + resetTrustedProxyBlockListCache(); + const app = createApp(); + app.get('/peer-ip', (req, res) => res.json({ ip: req.ip })); + + const res = await request(app) + .get('/peer-ip') + .set('X-Forwarded-For', '203.0.113.50'); + + expect(res.status).toBe(200); + expect(res.body.ip).toBe('203.0.113.50'); + }); + + it('ignores a forwarded HTTPS scheme from an untrusted direct peer', async () => { + const app = createApp(); + app.get('/request-scheme', (req, res) => { + res.json({ protocol: req.protocol, secure: isSecureRequest(req) }); + }); + + const res = await request(app) + .get('/request-scheme') + .set('X-Forwarded-Proto', 'https'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ protocol: 'http', secure: false }); + }); + + it('honors a forwarded HTTPS scheme from an allowlisted proxy peer', async () => { + process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8'; + resetTrustedProxyBlockListCache(); + const app = createApp(); + app.get('/request-scheme', (req, res) => { + res.json({ protocol: req.protocol, secure: isSecureRequest(req) }); + }); + + const res = await request(app) + .get('/request-scheme') + .set('X-Forwarded-Proto', 'https'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ protocol: 'https', secure: true }); + }); +}); diff --git a/backend/src/__tests__/trusted-proxy-cidrs.test.ts b/backend/src/__tests__/trusted-proxy-cidrs.test.ts index aad68fda..097cda75 100644 --- a/backend/src/__tests__/trusted-proxy-cidrs.test.ts +++ b/backend/src/__tests__/trusted-proxy-cidrs.test.ts @@ -23,6 +23,13 @@ describe('trustedProxyCidrs', () => { expect(isTrustedProxyPeer('192.168.1.1')).toBe(false); }); + it('matches IPv4-mapped IPv6 peers against IPv4 CIDRs', () => { + process.env.SENCHO_TRUSTED_PROXY_CIDRS = '10.0.0.0/8'; + resetTrustedProxyBlockListCache(); + expect(isTrustedProxyPeer('::ffff:10.1.2.3')).toBe(true); + expect(isTrustedProxyPeer('::ffff:192.168.1.1')).toBe(false); + }); + it('fails closed on invalid entries', () => { process.env.SENCHO_TRUSTED_PROXY_CIDRS = 'not-a-cidr'; resetTrustedProxyBlockListCache(); diff --git a/backend/src/__tests__/webhooks-git-source.test.ts b/backend/src/__tests__/webhooks-git-source.test.ts index d000dc82..b0bd2a7a 100644 --- a/backend/src/__tests__/webhooks-git-source.test.ts +++ b/backend/src/__tests__/webhooks-git-source.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; let tmpDir: string; @@ -25,6 +26,7 @@ function seedGitSource(stackName: string): void { env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, + encrypted_ca_bundle: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -236,4 +238,142 @@ describe('node-aware Git source webhooks', () => { expect(history[0].status).toBe('success'); expect(history[0].error).toMatch(/debounced/i); }); + + it('forwards a provider delivery id to a remote node under the webhook namespace', async () => { + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'remote-delivery-id-webhook', + type: 'remote', + compose_dir: '/tmp', + is_default: false, + api_url: 'http://remote-delivery.example', + api_token: 'remote-token', + }); + const webhookId = db.addWebhook({ + node_id: remoteNodeId, + name: 'delivery id remote git', + stack_name: 'remote-stack', + action: 'git-pull', + secret: WebhookService.getInstance().generateSecret(), + enabled: true, + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ status: 'success', message: 'Fetched.' }), { status: 200 }), + ); + + const webhook = db.getWebhook(webhookId)!; + const deliverySourceId = db.getGlobalSettings().delivery_source_id; + const result = await WebhookService.getInstance().execute( + webhook, + 'git-pull', + 'test', + undefined, + 'provider-delivery-1', + ); + + expect(result.success).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith( + 'http://remote-delivery.example/api/stacks/remote-stack/git-source/webhook-pull', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining(`"deliveryId":"webhook:${deliverySourceId}:${webhookId}:provider-delivery-1"`), + }), + ); + }); + + it('passes the same producer-scoped delivery identity to a local Git source', async () => { + const db = DatabaseService.getInstance(); + const nodeId = db.getDefaultNode()!.id; + const webhookId = db.addWebhook({ + node_id: nodeId, + name: 'delivery id local git', + stack_name: 'local-delivery-stack', + action: 'git-pull', + secret: WebhookService.getInstance().generateSecret(), + enabled: true, + }); + const webhook = db.getWebhook(webhookId)!; + const deliverySourceId = db.getGlobalSettings().delivery_source_id; + const { FileSystemService } = await import('../services/FileSystemService'); + const { GitSourceService } = await import('../services/GitSourceService'); + const stacksSpy = vi.spyOn(FileSystemService.prototype, 'getStacks') + .mockResolvedValue(['local-delivery-stack']); + const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull') + .mockResolvedValue({ status: 'success', message: 'Fetched.' }); + + try { + const result = await WebhookService.getInstance().execute( + webhook, + 'git-pull', + 'test', + undefined, + 'provider-delivery-2', + ); + + expect(result).toEqual({ success: true, duration_ms: expect.any(Number) }); + expect(pullSpy).toHaveBeenCalledWith( + 'local-delivery-stack', + true, + `webhook:${deliverySourceId}:${webhookId}:provider-delivery-2`, + ); + + const secondWebhookId = db.addWebhook({ + node_id: nodeId, + name: 'second delivery id local git', + stack_name: 'local-delivery-stack', + action: 'git-pull', + secret: webhook.secret, + enabled: true, + }); + pullSpy.mockClear(); + await WebhookService.getInstance().execute( + db.getWebhook(secondWebhookId)!, + 'git-pull', + 'test', + undefined, + 'provider-delivery-2', + ); + expect(pullSpy).toHaveBeenCalledWith( + 'local-delivery-stack', + true, + `webhook:${deliverySourceId}:${secondWebhookId}:provider-delivery-2`, + ); + + db.updateGlobalSetting('delivery_source_id', 'second-control-source'); + pullSpy.mockClear(); + await WebhookService.getInstance().execute( + webhook, + 'git-pull', + 'test', + undefined, + 'provider-delivery-2', + ); + expect(pullSpy).toHaveBeenCalledWith( + 'local-delivery-stack', + true, + `webhook:second-control-source:${webhookId}:provider-delivery-2`, + ); + db.updateGlobalSetting('delivery_source_id', deliverySourceId!); + + const oversizedDeliveryId = 'x'.repeat(300); + const boundedId = crypto.createHash('sha256').update(oversizedDeliveryId).digest('hex'); + pullSpy.mockClear(); + await WebhookService.getInstance().execute( + webhook, + 'git-pull', + 'test', + undefined, + oversizedDeliveryId, + ); + expect(pullSpy).toHaveBeenCalledWith( + 'local-delivery-stack', + true, + `webhook:${deliverySourceId}:${webhookId}:sha256:${boundedId}`, + ); + } finally { + if (deliverySourceId) db.updateGlobalSetting('delivery_source_id', deliverySourceId); + stacksSpy.mockRestore(); + pullSpy.mockRestore(); + } + }); }); diff --git a/backend/src/__tests__/webhooks-trigger.test.ts b/backend/src/__tests__/webhooks-trigger.test.ts index 977e1b8c..a03dd246 100644 --- a/backend/src/__tests__/webhooks-trigger.test.ts +++ b/backend/src/__tests__/webhooks-trigger.test.ts @@ -251,6 +251,55 @@ describe('POST /api/webhooks/:id/trigger: authenticated happy path', () => { expect(res.body).toMatchObject({ action: 'start' }); }); + it('extracts a recognized provider delivery header and passes it through to execute', async () => { + const { id, secret } = createWebhook({ action: 'stop' }); + const body = '{}'; + const executeSpy = vi.spyOn(WebhookService.getInstance(), 'execute').mockResolvedValue({ success: true, duration_ms: 0 }); + + try { + await request(app) + .post(`/api/webhooks/${id}/trigger`) + .set('Content-Type', 'application/json') + .set('X-Webhook-Signature', sign(body, secret)) + .set('X-GitHub-Delivery', 'gh-delivery-123') + .send(body); + + expect(executeSpy).toHaveBeenCalledWith( + expect.objectContaining({ id }), + 'stop', + expect.anything(), + true, + 'gh-delivery-123', + ); + } finally { + executeSpy.mockRestore(); + } + }); + + it('passes no delivery id through when the caller sends no recognized header', async () => { + const { id, secret } = createWebhook({ action: 'stop' }); + const body = '{}'; + const executeSpy = vi.spyOn(WebhookService.getInstance(), 'execute').mockResolvedValue({ success: true, duration_ms: 0 }); + + try { + await request(app) + .post(`/api/webhooks/${id}/trigger`) + .set('Content-Type', 'application/json') + .set('X-Webhook-Signature', sign(body, secret)) + .send(body); + + expect(executeSpy).toHaveBeenCalledWith( + expect.objectContaining({ id }), + 'stop', + expect.anything(), + true, + undefined, + ); + } finally { + executeSpy.mockRestore(); + } + }); + it('rejects an unknown action override with 400 after the signature passes (L2)', async () => { const { id, secret } = createWebhook(); const body = '{"action":"nuke-the-cluster"}'; diff --git a/backend/src/app.ts b/backend/src/app.ts index 4755a954..4bef3c4a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,6 +6,7 @@ import helmet from 'helmet'; import { globalApiLimiter, pollingLimiter } from './middleware/rateLimiters'; import { conditionalJsonParser } from './middleware/jsonParser'; import { nodeContextMiddleware } from './middleware/nodeContext'; +import { isTrustedProxyPeer } from './helpers/trustedProxyCidrs'; import { normalizeAcceptEncoding } from './middleware/normalizeAcceptEncoding'; import './types/express'; @@ -43,9 +44,8 @@ import './types/express'; export function createApp(): express.Express { const app = express(); - // 1. Trust the first reverse proxy (nginx, Traefik, etc.) for correct - // req.protocol, req.ip, and secure cookie detection behind a proxy. - app.set('trust proxy', 1); + // 1. Trust forwarding headers only from explicitly configured proxy peers. + app.set('trust proxy', (address: string) => isTrustedProxyPeer(address)); // 2. Security headers. // crossOriginEmbedderPolicy: disabled because Monaco editor workers lack COEP headers. diff --git a/backend/src/bootstrap/shutdown.ts b/backend/src/bootstrap/shutdown.ts index 82c62a03..f5951cbd 100644 --- a/backend/src/bootstrap/shutdown.ts +++ b/backend/src/bootstrap/shutdown.ts @@ -9,6 +9,7 @@ import { FleetSyncRetryService } from '../services/FleetSyncRetryService'; import { SuppressionRetractionRetryService } from '../services/SuppressionRetractionRetryService'; import { DockerEventManager } from '../services/DockerEventManager'; import { ImageUpdateService } from '../services/ImageUpdateService'; +import { SourceController } from '../services/gitops/SourceController'; import { SchedulerService } from '../services/SchedulerService'; import { MfaService } from '../services/MfaService'; import { MeshService } from '../services/MeshService'; @@ -49,6 +50,9 @@ export function installShutdownHandlers(server: Server): void { try { ImageUpdateService.getInstance().stop(); } catch (e) { console.warn('[Shutdown] ImageUpdateService cleanup failed:', (e as Error).message); } + try { SourceController.getInstance().stop(); } catch (e) { + console.warn('[Shutdown] SourceController cleanup failed:', (e as Error).message); + } try { SchedulerService.getInstance().stop(); } catch (e) { console.warn('[Shutdown] SchedulerService cleanup failed:', (e as Error).message); } diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts index e9453541..d7b97608 100644 --- a/backend/src/bootstrap/startup.ts +++ b/backend/src/bootstrap/startup.ts @@ -28,10 +28,11 @@ import { applyPilotModeCapabilityFilter } from '../services/CapabilityRegistry'; import { PilotTunnelManager } from '../services/PilotTunnelManager'; import { PilotMetrics } from '../services/PilotMetrics'; import { invalidateRemoteMetaCache } from '../helpers/cacheInvalidation'; -import { sweepStaleTempDirs as sweepStaleGitTempDirs, sweepGitManifestOrphans } from '../services/GitSourceService'; +import { GitSourceService, sweepStaleTempDirs as sweepStaleGitTempDirs, sweepGitManifestOrphans } from '../services/GitSourceService'; import { assertCreatesSettled, reclassifyInterruptedOperations, resolveInterruptedCreates } from '../services/gitops/createRecovery'; import { loadMigrationManifests, migrateDirectGitStacks, migrateInlineBlueprints } from '../services/gitops/migrate'; import { setGitOpsEventSink } from '../services/gitops/publish'; +import { SourceController } from '../services/gitops/SourceController'; import { NotificationService } from '../services/NotificationService'; import { sanitizeForLog } from '../utils/safeLog'; import { PORT } from '../helpers/constants'; @@ -98,6 +99,33 @@ function clearSelfContainerNotificationRouting(): void { } } +/** + * Reconcile-attempt recovery, then the managed-area sweep, in that fixed + * order: an attempt reserved but never settled (a crash between the two) + * must be resolved from durable state before the sweep or + * SourceController's own timer (started later in startServer, after + * registry delivery recovery settles per AUD-36) can race a recovery pass + * over the same attempts. Exported so this ordering is directly testable + * without driving the rest of startServer's unrelated service + * initialization. + */ +export async function runGitOpsSourceRecovery(): Promise { + try { + await GitSourceService.getInstance().recoverUnsettledReconcileAttempts(); + } catch (err) { + console.error('[GitSource] Reconcile-attempt recovery failed:', err instanceof Error ? err.stack ?? err.message : String(err)); + } + + // The managed-area sweep follows. It preserves anything whose ownership it + // cannot prove, so a failure here can only leave files behind, never remove + // the wrong ones, and retrying next boot is safe. + try { + await sweepGitManifestOrphans(); + } catch (err) { + console.warn('[GitManifest] Managed-area sweep failed:', err instanceof Error ? err.message : String(err)); + } +} + /** * Run the startup sequence: stack-directory migration, service initialization, * background watchdogs, then bind the HTTP server. The caller passes the @@ -233,14 +261,9 @@ export async function startServer(server: Server): Promise { console.error('[GitOps] Migration of pre-existing blueprints failed:', err instanceof Error ? err.stack ?? err.message : String(err)); } - // The managed-area sweep follows. It preserves anything whose ownership it - // cannot prove, so a failure here can only leave files behind, never remove - // the wrong ones, and retrying next boot is safe. - try { - await sweepGitManifestOrphans(); - } catch (err) { - console.warn('[GitManifest] Managed-area sweep failed:', err instanceof Error ? err.message : String(err)); - } + // Both steps must settle before SourceController starts below; see that + // function's own doc comment for why. + await runGitOpsSourceRecovery(); // Registry delivery recovery sweeps must settle before any mutation-capable // producer starts (AUD-36). @@ -287,6 +310,7 @@ export async function startServer(server: Server): Promise { FleetSyncRetryService.getInstance().start(); SuppressionRetractionRetryService.getInstance().start(); ImageUpdateService.getInstance().start(); + SourceController.getInstance().start(); SchedulerService.getInstance().start(); MfaService.getInstance().start(); MeshService.getInstance().start().catch((err) => { diff --git a/backend/src/helpers/assertStackExistsOnNode.ts b/backend/src/helpers/assertStackExistsOnNode.ts index 6d4f0d0b..4721a636 100644 --- a/backend/src/helpers/assertStackExistsOnNode.ts +++ b/backend/src/helpers/assertStackExistsOnNode.ts @@ -6,6 +6,7 @@ import { PROXY_TIER_HEADER } from '../services/license-headers'; import { LicenseService } from '../services/LicenseService'; import { isValidStackName } from '../utils/validation'; import { getErrorMessage } from '../utils/errors'; +import { safeAxiosTransport } from '../utils/outboundTarget'; const REMOTE_STACKS_TIMEOUT_MS = 30_000; @@ -59,6 +60,7 @@ export async function assertStackExistsOnNode( try { const res = await axios.get(`${baseUrl}/api/stacks`, { + ...safeAxiosTransport(target.trustedLoopback), headers, timeout: REMOTE_STACKS_TIMEOUT_MS, validateStatus: () => true, diff --git a/backend/src/helpers/cookies.ts b/backend/src/helpers/cookies.ts index 066f2689..d0314793 100644 --- a/backend/src/helpers/cookies.ts +++ b/backend/src/helpers/cookies.ts @@ -2,7 +2,7 @@ import type { Request } from 'express'; /** True when the request arrived over HTTPS, either directly or via a trusted TLS-terminating proxy. */ export const isSecureRequest = (req: Request): boolean => { - return req.secure || req.headers['x-forwarded-proto'] === 'https'; + return req.secure; }; /** diff --git a/backend/src/helpers/fleetLabelSummary.ts b/backend/src/helpers/fleetLabelSummary.ts index 66030572..4396cdf2 100644 --- a/backend/src/helpers/fleetLabelSummary.ts +++ b/backend/src/helpers/fleetLabelSummary.ts @@ -1,6 +1,7 @@ import { DatabaseService, type Node } from '../services/DatabaseService'; import { FileSystemService } from '../services/FileSystemService'; import { NodeRegistry } from '../services/NodeRegistry'; +import { safeRemoteFetch } from '../utils/outboundTarget'; import { formatNoTargetError } from '../utils/remoteTarget'; import { getErrorMessage } from '../utils/errors'; @@ -89,8 +90,16 @@ async function summarizeRemoteNode(node: Node): Promise { if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; try { const [labelsRes, assignmentsRes] = await Promise.all([ - fetch(`${base}/api/labels`, { headers, signal: AbortSignal.timeout(SUMMARY_FETCH_TIMEOUT_MS) }), - fetch(`${base}/api/labels/assignments`, { headers, signal: AbortSignal.timeout(SUMMARY_FETCH_TIMEOUT_MS) }), + safeRemoteFetch( + `${base}/api/labels`, + { headers, signal: AbortSignal.timeout(SUMMARY_FETCH_TIMEOUT_MS) }, + target.trustedLoopback, + ), + safeRemoteFetch( + `${base}/api/labels/assignments`, + { headers, signal: AbortSignal.timeout(SUMMARY_FETCH_TIMEOUT_MS) }, + target.trustedLoopback, + ), ]); if (!labelsRes.ok || !assignmentsRes.ok) { // Surface the remote's own error body (e.g. a token/tier message) the same diff --git a/backend/src/helpers/fleetPrune.ts b/backend/src/helpers/fleetPrune.ts index d814bbd1..d0b11a52 100644 --- a/backend/src/helpers/fleetPrune.ts +++ b/backend/src/helpers/fleetPrune.ts @@ -2,6 +2,7 @@ import type { Node } from '../services/DatabaseService'; import DockerController from '../services/DockerController'; import { FileSystemService } from '../services/FileSystemService'; import { NodeRegistry } from '../services/NodeRegistry'; +import { safeRemoteFetch } from '../utils/outboundTarget'; import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; import { PrunePlanStaleError, @@ -343,12 +344,12 @@ async function fetchRemotePlan(node: Node, targets: FleetPruneTarget[], scope: P const headers: Record = { 'Content-Type': 'application/json' }; if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`; try { - const response = await fetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/system/prune/plan`, { + const response = await safeRemoteFetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/system/prune/plan`, { method: 'POST', headers, body: JSON.stringify({ targets, scope }), signal: AbortSignal.timeout(REMOTE_PLAN_TIMEOUT_MS), - }); + }, proxyTarget.trustedLoopback); const data: unknown = await response.json().catch(() => null); if (!response.ok) { const message = data && typeof data === 'object' && typeof (data as { error?: unknown }).error === 'string' @@ -471,12 +472,12 @@ async function executeRemote(entry: Preflight, targets: FleetPruneTarget[], scop const headers: Record = { 'Content-Type': 'application/json' }; if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`; try { - const response = await fetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/system/prune/system`, { + const response = await safeRemoteFetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/system/prune/system`, { method: 'POST', headers, body: JSON.stringify({ targets, scope, planFingerprint: plan.fingerprint }), signal: AbortSignal.timeout(REMOTE_PLAN_TIMEOUT_MS), - }); + }, proxyTarget.trustedLoopback); const data: unknown = await response.json().catch(() => null); const record = data && typeof data === 'object' ? data as Record : null; if (!response.ok) { diff --git a/backend/src/helpers/notificationSuppressionSync.ts b/backend/src/helpers/notificationSuppressionSync.ts index 922dc526..84b65736 100644 --- a/backend/src/helpers/notificationSuppressionSync.ts +++ b/backend/src/helpers/notificationSuppressionSync.ts @@ -8,6 +8,7 @@ import { } from '../services/CapabilityRegistry'; import { remoteAdvertisesCapability } from './remoteCapabilities'; import { getErrorMessage } from '../utils/errors'; +import { safeRemoteFetch } from '../utils/outboundTarget'; const SYNC_TIMEOUT_MS = 15_000; @@ -91,10 +92,14 @@ function clearPending(nodeId: number, ruleId: number): void { DatabaseService.getInstance().deleteNotificationSuppressionPendingRetraction(ruleId, nodeId); } -function resolveRemoteApi(node: Node): { baseUrl: string; apiToken: string } | null { +function resolveRemoteApi(node: Node): { baseUrl: string; apiToken: string; trustedLoopback: boolean } | null { const target = NodeRegistry.getInstance().getProxyTarget(node.id); if (!target?.apiUrl) return null; - return { baseUrl: target.apiUrl.replace(/\/$/, ''), apiToken: target.apiToken }; + return { + baseUrl: target.apiUrl.replace(/\/$/, ''), + apiToken: target.apiToken, + trustedLoopback: target.trustedLoopback, + }; } /** Non-2xx (including opaque 404) is always failure; never treat missing routes as applied. */ @@ -119,12 +124,12 @@ async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Pr console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`); return; } - const res = await fetch(`${remote.baseUrl}/api/notification-suppression-rules/replica`, { + const res = await safeRemoteFetch(`${remote.baseUrl}/api/notification-suppression-rules/replica`, { method: 'POST', headers: buildRemoteHeaders(remote.apiToken), body: JSON.stringify({ rule: replicaPayload(rule) }), signal: AbortSignal.timeout(SYNC_TIMEOUT_MS), - }); + }, remote.trustedLoopback); if (!res.ok) await throwHttpFailure(res); const outcome = await readOutcome(res); if (outcome !== 'applied') { @@ -148,12 +153,12 @@ export async function deleteRuleOnNode( throw new Error(err); } try { - const res = await fetch(`${remote.baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, { + const res = await safeRemoteFetch(`${remote.baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, { method: 'DELETE', headers: buildRemoteHeaders(remote.apiToken), body: JSON.stringify(retraction), signal: AbortSignal.timeout(SYNC_TIMEOUT_MS), - }); + }, remote.trustedLoopback); if (!res.ok) await throwHttpFailure(res); const outcome = await readOutcome(res); if (outcome !== 'applied') { diff --git a/backend/src/helpers/proxyExemptPaths.ts b/backend/src/helpers/proxyExemptPaths.ts index 0b9bce8d..ccad56b1 100644 --- a/backend/src/helpers/proxyExemptPaths.ts +++ b/backend/src/helpers/proxyExemptPaths.ts @@ -13,6 +13,7 @@ export const PROXY_EXEMPT_PREFIXES: readonly string[] = [ '/api/fleet/', '/api/webhooks', '/api/meta', + '/api/build-info', ]; /** Returns true when the path should bypass the remote proxy (handled locally). */ @@ -66,6 +67,7 @@ export const HUB_ONLY_PREFIXES: readonly string[] = [ '/api/node-labels/', '/api/registry-delivery/', '/api/sso/', + '/api/api-tokens/', ]; /** Returns true when the path is hub-only and must not be proxied to a remote node. */ diff --git a/backend/src/helpers/registryDeliveryOutbound.ts b/backend/src/helpers/registryDeliveryOutbound.ts index e2191470..7b878ac0 100644 --- a/backend/src/helpers/registryDeliveryOutbound.ts +++ b/backend/src/helpers/registryDeliveryOutbound.ts @@ -1,7 +1,8 @@ import axios from 'axios'; import type { Node } from '../services/DatabaseService'; import { DatabaseService } from '../services/DatabaseService'; -import { NodeRegistry } from '../services/NodeRegistry'; +import { NodeRegistry, type ProxyTarget } from '../services/NodeRegistry'; +import { safeAxiosTransport } from '../utils/outboundTarget'; import { PilotTunnelManager } from '../services/PilotTunnelManager'; import { RegistryDeliveryService } from '../services/RegistryDeliveryService'; import type { RegistryDeliveryDiscoverResponse } from '../services/RegistryDeliveryService'; @@ -26,7 +27,7 @@ export interface AugmentRegistryDeliveryInput { apiPath: string; nodeId: number; node: Node; - target: { apiUrl: string; apiToken: string }; + target: ProxyTarget; body: Record; sourceKind?: string; prepId?: string; @@ -61,7 +62,7 @@ export async function wouldAttemptRegistryDelivery( } async function callTargetDiscover( - target: { apiUrl: string; apiToken: string }, + target: ProxyTarget, body: Record, abortSignal?: AbortSignal, ): Promise { @@ -70,6 +71,7 @@ async function callTargetDiscover( } const base = target.apiUrl.replace(/\/$/, ''); const res = await axios.post(`${base}/api/registry-delivery/discover`, body, { + ...safeAxiosTransport(target.trustedLoopback), headers: { Authorization: `Bearer ${target.apiToken}` }, timeout: 30_000, maxBodyLength: REGISTRY_DELIVERY_FIELD_LIMIT_BYTES, diff --git a/backend/src/helpers/registryDeliveryProxy.ts b/backend/src/helpers/registryDeliveryProxy.ts index 3f3b85a3..7c4d440d 100644 --- a/backend/src/helpers/registryDeliveryProxy.ts +++ b/backend/src/helpers/registryDeliveryProxy.ts @@ -1,5 +1,6 @@ import type { Request, Response } from 'express'; import type { Node } from '../services/DatabaseService'; +import type { ProxyTarget } from '../services/NodeRegistry'; import { augmentJsonBodyForRegistryDelivery, wouldAttemptRegistryDelivery } from './registryDeliveryOutbound'; export interface RegistryDeliveryProxyResult { @@ -18,7 +19,7 @@ export async function augmentRemoteProxyWithRegistryDelivery( req: Request, nodeId: number, node: Node, - target: { apiUrl: string; apiToken: string }, + target: ProxyTarget, rawBody: Buffer, ): Promise { const apiPath = `/api${req.path}`; diff --git a/backend/src/helpers/selfUpdateCompose.ts b/backend/src/helpers/selfUpdateCompose.ts index 2c5f9c17..2ce0f100 100644 --- a/backend/src/helpers/selfUpdateCompose.ts +++ b/backend/src/helpers/selfUpdateCompose.ts @@ -104,13 +104,50 @@ export function isSenchoDevFloatingTag(imageRef: string): boolean { // A digest pin disqualifies the reference (e.g., `@sha256:...`) if (ref.includes('@sha256:') || ref.startsWith('sha256:')) return false; - // Extract the tag using the same logic as classifyImagePin. + const tag = extractTagFromRef(ref); + return tag === 'dev'; +} + +/** + * Build channel of a running or declared image, derived from the image + * reference alone. This is the canonical build identity classifier used by + * `/api/build-info` and the shell/About surfaces: it answers "is this a dev, + * preview, or stable build" from the ref, independent of the packaged semver + * (a dev image carries the last released version, so version alone cannot + * identify it). + * + * - dev repo (`ghcr.io/studio-saelix/sencho-dev`): always `'dev'`. An + * arbitrary `:dev-` tag still reads `dev` (a deliberate operator + * choice); the immutable revision is surfaced separately from the digest. + * - stable repos (`ghcr.io/studio-saelix/sencho-hardened`, `saelix/sencho`, + * `ghcr.io/studio-saelix/sencho`): `pr-` and `preview-` tags are + * `'preview'` (CI builds on the stable repo that are not releases); + * everything else is `'stable'`. + * - any other repository: `'unknown'`. + */ +export type BuildChannel = 'stable' | 'dev' | 'preview' | 'unknown'; + +export function classifyBuildChannel(imageRef: string): BuildChannel { + const repository = normalizeImageRepository(imageRef); + if (repository === 'ghcr.io/studio-saelix/sencho-dev') return 'dev'; + if ( + repository === 'ghcr.io/studio-saelix/sencho-hardened' || + repository === 'saelix/sencho' || + repository === 'ghcr.io/studio-saelix/sencho' + ) { + const tag = extractTagFromRef(imageRef.trim()); + if (tag && (/^pr-\d+$/.test(tag) || /^preview-[0-9a-f]{7,40}$/.test(tag))) return 'preview'; + return 'stable'; + } + return 'unknown'; +} + +/** Extract the tag portion of an image ref (`.../repo:tag`), or '' when absent. */ +function extractTagFromRef(ref: string): string { const lastSlash = ref.lastIndexOf('/'); const lastColon = ref.lastIndexOf(':'); // A colon after the last slash is a tag separator; before it is a registry port. - const tag = lastColon > lastSlash ? ref.slice(lastColon + 1) : ''; - - return tag === 'dev'; + return lastColon > lastSlash ? ref.slice(lastColon + 1) : ''; } /** diff --git a/backend/src/helpers/stackRouteAuth.ts b/backend/src/helpers/stackRouteAuth.ts index 2a06bac4..096c0a9e 100644 --- a/backend/src/helpers/stackRouteAuth.ts +++ b/backend/src/helpers/stackRouteAuth.ts @@ -89,6 +89,9 @@ const EXACT_SUFFIX_RULES: readonly SuffixRule[] = [ { method: 'POST', suffix: '/git-source/webhook-pull', action: 'stack:edit' }, { method: 'POST', suffix: '/git-source/dismiss-pending', action: 'stack:edit' }, { method: 'POST', suffix: '/git-source/browse', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/suspend', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/resume', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/retry', action: 'stack:edit' }, // Deploy { method: 'POST', suffix: '/deploy', action: 'stack:deploy' }, diff --git a/backend/src/helpers/trustedProxyCidrs.ts b/backend/src/helpers/trustedProxyCidrs.ts index daca5c2a..e3fcbd65 100644 --- a/backend/src/helpers/trustedProxyCidrs.ts +++ b/backend/src/helpers/trustedProxyCidrs.ts @@ -30,7 +30,7 @@ function parseCidrEntry(raw: string): { family: 4 | 6; address: string; prefix: /** * Parse SENCHO_TRUSTED_PROXY_CIDRS once at process start. Invalid or duplicate - * entries fail closed by returning null (treat all upgrades as non-confidential). + * entries fail closed by returning null, so forwarding headers are ignored. */ export function getTrustedProxyBlockList(): net.BlockList | null { if (cachedBlockList !== undefined) { @@ -91,12 +91,14 @@ export function isTrustedProxyPeer(peerAddress: string | undefined): boolean { const blockList = getTrustedProxyBlockList(); if (!blockList) return false; - const family = net.isIP(peerAddress); + const mappedIpv4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(peerAddress)?.[1]; + const normalizedPeer = mappedIpv4 ?? peerAddress; + const family = net.isIP(normalizedPeer); if (family === 4) { - return blockList.check(peerAddress, 'ipv4'); + return blockList.check(normalizedPeer, 'ipv4'); } if (family === 6) { - return blockList.check(peerAddress, 'ipv6'); + return blockList.check(normalizedPeer, 'ipv6'); } return false; } diff --git a/backend/src/index.ts b/backend/src/index.ts index 2fe9e10d..587e5240 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -19,6 +19,7 @@ import { mfaRouter } from './routes/mfa'; import { ssoRouter } from './routes/sso'; import { licenseRouter, systemUpdateRouter } from './routes/license'; import { imageChannelRouter } from './routes/imageChannel'; +import { buildInfoRouter } from './routes/buildInfo'; import { webhooksRouter } from './routes/webhooks'; import { usersRouter } from './routes/users'; import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources'; @@ -115,6 +116,7 @@ app.use('/api/', createRemoteProxyMiddleware()); app.use('/api/license', licenseRouter); app.use('/api/license/image-channel', imageChannelRouter); +app.use('/api/build-info', buildInfoRouter); app.use('/api/system', systemUpdateRouter); app.use('/api/permissions', permissionsRouter); app.use('/api/convert', convertRouter); diff --git a/backend/src/middleware/rateLimiters.ts b/backend/src/middleware/rateLimiters.ts index 6fc2302c..3c9349ff 100644 --- a/backend/src/middleware/rateLimiters.ts +++ b/backend/src/middleware/rateLimiters.ts @@ -14,7 +14,7 @@ import { DatabaseService } from '../services/DatabaseService'; // with a 300/min safety net to prevent resource exhaustion. // Tier W (Webhooks): CI/CD webhook triggers at 500/min (shared datacenter IPs). // Tier 2 (Standard): All other endpoints at 200/min. -// Tier 3 (Auth): Strict brute-force protection (5-10 attempts / 15min). +// Tier 3 (Auth): Login protection at 5 attempts/IP per 15 minutes. // // Enterprise adaptations: // - Internal node-to-node traffic (node_proxy JWTs) bypasses all rate limiters. diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index 66b05d27..76ef77af 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -52,6 +52,12 @@ import type { PermissionAction } from '../middleware/permissions'; import { SETTING_WRITE_PERMISSIONS } from '../routes/settings'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { RegistryDeliveryService } from '../services/RegistryDeliveryService'; +import { + assertSafeOutboundUrl, + safeHttpAgent, + safeHttpsAgent, + UnsafeOutboundTargetError, +} from '../utils/outboundTarget'; import { classifyRegistryDeliveryRouteClass, getRegistryDeliveryTotalBodyLimit, @@ -318,7 +324,11 @@ export function createRemoteProxyMiddleware(): RequestHandler { pathRewrite: (path: string) => '/api' + path, }; - const proxy = createProxyMiddleware({ ...baseOptions, on: sharedOn }); + const createStreamingProxy = (agent?: typeof safeHttpAgent | typeof safeHttpsAgent) => + createProxyMiddleware({ ...baseOptions, ...(agent ? { agent } : {}), on: sharedOn }); + const proxy = createStreamingProxy(); + const safeHttpProxy = createStreamingProxy(safeHttpAgent); + const safeHttpsProxy = createStreamingProxy(safeHttpsAgent); /** * The identity hop: buffers the response so node ids inside it can be @@ -328,8 +338,10 @@ export function createRemoteProxyMiddleware(): RequestHandler { * buffering is exactly what the streaming hop must never do. Logs, event * streams, and downloads keep flowing through that one untouched. */ - const identityProxy = createProxyMiddleware({ + const createIdentityProxy = (agent?: typeof safeHttpAgent | typeof safeHttpsAgent) => + createProxyMiddleware({ ...baseOptions, + ...(agent ? { agent } : {}), selfHandleResponse: true, // Bounded so a remote that sends headers and then stalls cannot pin the // buffered body and both sockets indefinitely. No pathFilter: the @@ -376,6 +388,9 @@ export function createRemoteProxyMiddleware(): RequestHandler { }, }, }); + const identityProxy = createIdentityProxy(); + const safeHttpIdentityProxy = createIdentityProxy(safeHttpAgent); + const safeHttpsIdentityProxy = createIdentityProxy(safeHttpsAgent); return (req: Request, res: Response, next: NextFunction): void => { // The `/api/` mount strips the `/api` prefix, so req.path is now `/auth/…`, @@ -418,6 +433,20 @@ export function createRemoteProxyMiddleware(): RequestHandler { } const runGatedProxy = async (): Promise => { + if (node.mode === 'proxy') { + try { + await assertSafeOutboundUrl(target.apiUrl); + } catch (error: unknown) { + if (!(error instanceof UnsafeOutboundTargetError)) throw error; + const message = error.reason === 'blocked' + ? 'Remote node target is not allowed.' + : 'Remote node target host could not be resolved.'; + console.warn(`[Proxy] Refused remote target for node ${node.id}: ${error.reason}`); + res.status(502).json({ error: message }); + return; + } + } + if (isStackDownWithRemoveVolumes(req)) { const supported = await remoteAdvertisesCapability(req.nodeId, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY); if (!supported) { @@ -779,7 +808,10 @@ export function createRemoteProxyMiddleware(): RequestHandler { } req.gitopsIdentity = { query: prepared.search.toString(), preRewritePath: req.path }; beginProxyTiming(req, res); - identityProxy(req, res, next); + const selectedIdentityProxy = node.mode === 'pilot_agent' + ? identityProxy + : target.apiUrl.startsWith('https:') ? safeHttpsIdentityProxy : safeHttpIdentityProxy; + selectedIdentityProxy(req, res, next); return; } @@ -787,7 +819,10 @@ export function createRemoteProxyMiddleware(): RequestHandler { if (req.registryDeliveryAbortController?.signal.aborted) { return; } - proxy(req, res, next); + const selectedProxy = node.mode === 'pilot_agent' + ? proxy + : target.apiUrl.startsWith('https:') ? safeHttpsProxy : safeHttpProxy; + selectedProxy(req, res, next); }; runGatedProxy().catch(next); diff --git a/backend/src/routes/buildInfo.ts b/backend/src/routes/buildInfo.ts new file mode 100644 index 00000000..7d999166 --- /dev/null +++ b/backend/src/routes/buildInfo.ts @@ -0,0 +1,47 @@ +import { Router, type Request, type Response } from 'express'; +import { requireUserSession } from '../middleware/tierGates'; +import { classifyImageChannel, type ImageChannel } from '../helpers/imageChannel'; +import type { BuildChannel } from '../helpers/selfUpdateCompose'; +import SelfIdentityService from '../services/SelfIdentityService'; + +export const buildInfoRouter = Router(); + +/** Wire shape of GET /api/build-info. `restricted: true` implies `imageRef` and + * `revision` are nulled for a hardened image viewed by a non-admin. */ +interface BuildInfoResponse { + version: string | null; + channel: BuildChannel; + imageChannel: ImageChannel; + imageRef: string | null; + imageId: string | null; + revision: string | null; + restricted: boolean; +} + +// Canonical runtime build identity of the control instance. Proxy-exempt (see +// helpers/proxyExemptPaths.ts) so it is always served by the local hub, never +// forwarded to a remote node. The running image reference can carry a private +// registry/repository name, so the endpoint requires a human session and +// redacts hardened-image references to non-admins via `restricted: true` (the +// UI shows "Restricted", never "Unknown", when set). +buildInfoRouter.get('/', async (req: Request, res: Response): Promise => { + if (!requireUserSession(req, res)) return; + const service = SelfIdentityService.getInstance(); + // Await the detached revision enrichment so a transient null is never the + // settled value of a successful response. Bounded by the inspect timeout and + // never rejects, so this adds at most a short wait on the first read. + await service.whenRevisionResolved(); + const identity = service.getBuildInfo(); + const isAdmin = req.user?.role === 'admin'; + const imageChannel = identity.imageRef ? classifyImageChannel(identity.imageRef) : 'unknown'; + const restricted = !isAdmin && imageChannel === 'hardened'; + res.json({ + version: identity.version, + channel: identity.channel, + imageChannel, + imageRef: restricted ? null : identity.imageRef, + imageId: identity.imageId, + revision: restricted ? null : identity.revision, + restricted, + } satisfies BuildInfoResponse); +}); \ No newline at end of file diff --git a/backend/src/routes/diagnostics.ts b/backend/src/routes/diagnostics.ts index c026ea22..db4b5c7e 100644 --- a/backend/src/routes/diagnostics.ts +++ b/backend/src/routes/diagnostics.ts @@ -43,12 +43,12 @@ diagnosticsRouter.get('/', async (req: Request, res: Response): Promise => // compose directory and its host path mapping, TLS, disk headroom). Same admin // session gate as the recovery report. proto / host come from the request so // the TLS verdict reflects how this browser reached the dashboard; behind a -// reverse proxy that terminates TLS, x-forwarded-proto carries the real scheme. +// trusted reverse proxy that terminates TLS, req.protocol carries the real scheme. diagnosticsRouter.get('/environment', async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; if (!requireAdmin(req, res)) return; try { - const proto = (req.get('x-forwarded-proto')?.split(',')[0].trim()) || req.protocol; + const proto = req.protocol; const host = req.get('host') || ''; const report = await collectEnvironmentReport(buildRealProbes({ proto, host })); try { diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 0b7704dd..7d0f91f3 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -7,7 +7,7 @@ import { DatabaseService, type Node, type StackDossierFields } from '../services import { ControlIdentityMismatchError, FleetSyncService, StaleSyncPushError } from '../services/FleetSyncService'; import { MAX_SYNC_ROWS, SYNC_ERROR_CODES } from '../services/fleetSyncConstants'; import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPDATE_TIMEOUT_MS, UPDATE_TIMEOUT_MSG, TERMINAL_TTL_MS } from '../services/FleetUpdateTrackerService'; -import { NodeRegistry } from '../services/NodeRegistry'; +import { NodeRegistry, type ProxyTarget } from '../services/NodeRegistry'; import { computeNodeNetworkingSummary, type NodeNetworkingSummary } from '../services/network/networkingSummary'; import DockerController from '../services/DockerController'; import { getHostMemory, memoryToWire, type MemoryWire } from '../helpers/hostMemory'; @@ -79,6 +79,7 @@ import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest'; import { PROXY_TIER_HEADER, deployProvenanceHeaders } from '../services/license-headers'; import { LicenseService } from '../services/LicenseService'; +import { safeRemoteFetch } from '../utils/outboundTarget'; const updateTracker = FleetUpdateTrackerService.getInstance(); /** Sync lock for remote reapply while meta is fetched (before the pollable tracker exists). */ @@ -391,9 +392,9 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise try { const [statsRes, systemStatsRes, stacksRes] = await Promise.allSettled([ - fetch(`${baseUrl}/api/stats`, { headers, signal: AbortSignal.timeout(10000) }), - fetch(`${baseUrl}/api/system/stats`, { headers, signal: AbortSignal.timeout(10000) }), - fetch(`${baseUrl}/api/stacks`, { headers, signal: AbortSignal.timeout(10000) }), + safeRemoteFetch(`${baseUrl}/api/stats`, { headers, signal: AbortSignal.timeout(10000) }, target.trustedLoopback), + safeRemoteFetch(`${baseUrl}/api/system/stats`, { headers, signal: AbortSignal.timeout(10000) }, target.trustedLoopback), + safeRemoteFetch(`${baseUrl}/api/stacks`, { headers, signal: AbortSignal.timeout(10000) }, target.trustedLoopback), ]); interface RemoteSystemStats { @@ -684,7 +685,7 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp } try { - const resp = await fetch( + const resp = await safeRemoteFetch( `${target.apiUrl.replace(/\/$/, '')}/api/dashboard/configuration`, { headers: { @@ -693,6 +694,7 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp }, signal: AbortSignal.timeout(10000), }, + target.trustedLoopback, ); const raw = resp.ok ? (await resp.json() as ConfigurationStatus) : null; const configuration = raw ? normalizeRemoteConfigurationStatus(raw) : null; @@ -703,7 +705,11 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp status: configuration ? 'online' : 'offline', configuration, }; - } catch { + } catch (error: unknown) { + console.warn( + `[Fleet] Configuration fetch failed for node "${sanitizeForLog(node.name)}":`, + getErrorMessage(error, 'unknown'), + ); return { id: node.id, name: node.name, type: 'remote', status: 'offline', configuration: null }; } }), @@ -746,12 +752,13 @@ fleetRouter.get('/dependency-map', authMiddleware, async (req: Request, res: Res return { nodeId: node.id, nodeName: node.name, status: 'error', graph: null, error: formatNoTargetError(node) }; } - const resp = await fetch( + const resp = await safeRemoteFetch( `${target.apiUrl.replace(/\/$/, '')}/api/dependency-map/node-graph`, { headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) }, signal: AbortSignal.timeout(15000), }, + target.trustedLoopback, ); if (!resp.ok) { const errBody = await resp.json().catch(() => null) as { error?: string } | null; @@ -877,12 +884,13 @@ fleetRouter.get('/container-labels', authMiddleware, async (req: Request, res: R } const revealQs = options.revealSecrets ? '?reveal=1' : ''; - const resp = await fetch( + const resp = await safeRemoteFetch( `${target.apiUrl.replace(/\/$/, '')}/api/system/container-labels${revealQs}`, { headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) }, signal: AbortSignal.timeout(30000), }, + target.trustedLoopback, ); if (!resp.ok) { const errBody = await resp.json().catch(() => null) as { error?: string } | null; @@ -986,9 +994,10 @@ fleetRouter.get('/networking-summary', authMiddleware, async (req: Request, res: if (!target) { return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: formatNoTargetError(node) }; } - const resp = await fetch( + const resp = await safeRemoteFetch( `${target.apiUrl.replace(/\/$/, '')}/api/networking/summary`, { headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) }, signal: AbortSignal.timeout(15000) }, + target.trustedLoopback, ); if (!resp.ok) { return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: `Remote returned ${resp.status}` }; @@ -1036,10 +1045,10 @@ fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res res.status(503).json({ error: formatNoTargetError(node) }); return; } - const response = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/stacks`, { + const response = await safeRemoteFetch(`${target.apiUrl.replace(/\/$/, '')}/api/stacks`, { headers: target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}, signal: AbortSignal.timeout(10000), - }); + }, target.trustedLoopback); if (!response.ok) { res.status(502).json({ error: 'Failed to fetch stacks from remote node' }); return; @@ -1083,10 +1092,10 @@ fleetRouter.get('/node/:nodeId/stacks/:stackName/containers', authMiddleware, as res.status(503).json({ error: formatNoTargetError(node) }); return; } - const response = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(stackName)}/containers`, { + const response = await safeRemoteFetch(`${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(stackName)}/containers`, { headers: target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}, signal: AbortSignal.timeout(10000), - }); + }, target.trustedLoopback); if (!response.ok) { res.status(502).json({ error: 'Failed to fetch containers from remote node' }); return; @@ -1402,25 +1411,25 @@ fleetRouter.get('/update-status/release-notes', authMiddleware, async (req: Requ // sent as null/invalid), and an older remote that predates this field simply // ignores the extra body key and behaves as before. function postSystemEndpoint( - target: { apiUrl: string; apiToken: string }, + target: ProxyTarget, endpoint: '/api/system/update' | '/api/system/reapply-compose', body: Record = {}, ) { const headers: Record = { 'Content-Type': 'application/json' }; if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; - return fetch(`${target.apiUrl.replace(/\/$/, '')}${endpoint}`, { + return safeRemoteFetch(`${target.apiUrl.replace(/\/$/, '')}${endpoint}`, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(10000), - }); + }, target.trustedLoopback); } -function postSystemUpdate(target: { apiUrl: string; apiToken: string }, targetVersion?: string) { +function postSystemUpdate(target: ProxyTarget, targetVersion?: string) { return postSystemEndpoint(target, '/api/system/update', targetVersion ? { targetVersion } : {}); } -function postSystemReapplyCompose(target: { apiUrl: string; apiToken: string }) { +function postSystemReapplyCompose(target: ProxyTarget) { return postSystemEndpoint(target, '/api/system/reapply-compose'); } @@ -2039,7 +2048,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: try { const headers: Record = { 'Content-Type': 'application/json' }; if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; - const response = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/fleet-actions/labels/local-stop`, { + const response = await safeRemoteFetch(`${target.apiUrl.replace(/\/$/, '')}/api/fleet-actions/labels/local-stop`, { method: 'POST', headers, body: JSON.stringify({ @@ -2048,7 +2057,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: ...(allowedStacks ? { stackNames: [...allowedStacks] } : {}), }), signal: AbortSignal.timeout(60000), - }); + }, target.trustedLoopback); if (!response.ok) { const err = (await response.json().catch(() => ({}))) as { error?: string }; return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: err.error || `Remote returned ${response.status}` }; @@ -2218,12 +2227,12 @@ fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res try { const headers: Record = { 'Content-Type': 'application/json' }; if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`; - const response = await fetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/fleet-actions/labels/local-assign`, { + const response = await safeRemoteFetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/fleet-actions/labels/local-assign`, { method: 'POST', headers, body: JSON.stringify({ label: template, stackNames: target.stackNames }), signal: AbortSignal.timeout(60000), - }); + }, proxyTarget.trustedLoopback); if (!response.ok) { const err = (await response.json().catch(() => ({}))) as { error?: string }; const message = err.error || `Remote returned ${response.status}`; @@ -2452,12 +2461,12 @@ fleetRouter.post('/prune/estimate', authMiddleware, async (req: Request, res: Re // serialized and one failure should short-circuit later targets there.) const perTarget = await Promise.all(targets.map(async (target): Promise => { try { - const response = await fetch(`${baseUrl}/api/system/prune/estimate`, { + const response = await safeRemoteFetch(`${baseUrl}/api/system/prune/estimate`, { method: 'POST', headers: estimateHeaders, body: JSON.stringify({ target, scope }), signal: AbortSignal.timeout(15000), - }); + }, proxyTarget.trustedLoopback); if (!response.ok) { const errBody = (await response.json().catch(() => ({}))) as { error?: string }; return { bytes: 0, error: errBody.error || `Remote returned ${response.status}` }; @@ -2750,6 +2759,7 @@ class SnapshotProxyTargetError extends Error { interface RemoteProxyContext { baseUrl: string; headers: Record; + trustedLoopback: boolean; } // Builds an error from a failed remote response so the thrown message names the @@ -2787,7 +2797,11 @@ function buildRemoteProxyContext(node: Node): RemoteProxyContext | null { [PROXY_TIER_HEADER]: proxyHeaders.tier, }; if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`; - return { baseUrl: proxyTarget.apiUrl.replace(/\/$/, ''), headers }; + return { + baseUrl: proxyTarget.apiUrl.replace(/\/$/, ''), + headers, + trustedLoopback: proxyTarget.trustedLoopback, + }; } // Writes a snapshot stack's files back to its node. Existing stacks capture a @@ -2814,12 +2828,12 @@ async function applySnapshotStackFiles( const ctx = buildRemoteProxyContext(node); if (!ctx) throw new SnapshotProxyTargetError(formatNoTargetError(node)); - const applyRes = await fetch(`${ctx.baseUrl}/api/stacks/${encodeURIComponent(stackName)}/fleet-snapshot-apply`, { + const applyRes = await safeRemoteFetch(`${ctx.baseUrl}/api/stacks/${encodeURIComponent(stackName)}/fleet-snapshot-apply`, { method: 'POST', headers: ctx.headers, body: JSON.stringify({ files: applyFiles }), signal: AbortSignal.timeout(FLEET_SNAPSHOT_APPLY_TIMEOUT_MS), - }); + }, ctx.trustedLoopback); if (!applyRes.ok) throw await remoteStackError('Failed to restore stack files', applyRes); } @@ -2854,7 +2868,7 @@ async function redeploySnapshotStack(node: Node, stackName: string): Promise { - const { repo_url, branch, auth_type, token, deploy_key, ssh_known_hosts_entry } = req.body ?? {}; + const { repo_url, branch, auth_type, token, deploy_key, ssh_known_hosts_entry, ca_bundle } = req.body ?? {}; if (typeof repo_url !== 'string' || !repo_url.trim()) { res.status(400).json({ error: 'repo_url is required' }); return; @@ -56,6 +61,25 @@ async function handleBrowse( res.status(400).json({ error: repoUrlError }); return; } + const parsedRepo = parseStorableRepoUrl(repo_url); + if (!parsedRepo.ok) { + res.status(400).json({ error: 'Repository URL is invalid' }); + return; + } + const repoHostname = parsedRepo.kind === 'https' ? parsedRepo.url.hostname : parsedRepo.ssh.host; + try { + await assertSafeOutboundHostname(repoHostname); + } catch (error: unknown) { + if (error instanceof UnsafeOutboundTargetError) { + res.status(400).json({ + error: error.reason === 'blocked' + ? 'Repository host is not allowed' + : 'Repository host could not be resolved', + }); + return; + } + throw error; + } if (branch.length > MAX_BRANCH_LENGTH) { res.status(400).json({ error: 'The branch, tag, or commit SHA is too long.' }); return; @@ -72,6 +96,10 @@ async function handleBrowse( res.status(400).json({ error: 'deploy_key is too long' }); return; } + if (typeof ca_bundle === 'string' && ca_bundle.length > MAX_CA_BUNDLE_LENGTH) { + res.status(400).json({ error: 'ca_bundle is too long' }); + return; + } const explicitToken = typeof token === 'string' && token.trim() ? token : null; const effectiveToken = auth_type === 'token' ? (explicitToken ?? storedToken) : null; const explicitDeployKey = typeof deploy_key === 'string' && deploy_key.trim() ? deploy_key : null; @@ -81,11 +109,18 @@ async function handleBrowse( ? ssh_known_hosts_entry.trim() : storedKnownHosts) : null; + const explicitCaBundle = typeof ca_bundle === 'string' && ca_bundle.trim() ? ca_bundle.trim() : null; + const effectiveCaBundle = explicitCaBundle ?? storedCaBundle; + if (explicitCaBundle && !validateCaBundlePem(explicitCaBundle)) { + res.status(400).json({ error: 'ca_bundle must contain one or more PEM certificates' }); + return; + } const listParams: { repoUrl: string; branch: string; token?: string | null; sshAuth?: { privateKey: string; knownHostsEntry: string }; + caBundlePem?: string | null; } = { repoUrl: repo_url.trim(), branch: branch.trim(), @@ -95,6 +130,9 @@ async function handleBrowse( } else if (auth_type === 'deploy_key' && effectiveDeployKey && effectiveKnownHosts) { listParams.sshAuth = { privateKey: effectiveDeployKey, knownHostsEntry: effectiveKnownHosts }; } + if (effectiveCaBundle) { + listParams.caBundlePem = effectiveCaBundle; + } try { const result = await GitSourceService.getInstance().listRepoTree(listParams); res.json(result); @@ -133,13 +171,22 @@ gitSourcesRouter.post('/ssh-host-key', async (req: Request, res: Response): Prom res.status(400).json({ error: 'Host key probe requires an SSH repository URL' }); return; } - const keys = await scanHostKeys(parsed.host, parsed.port); + const [{ address }] = await resolveSafeOutboundHostname(parsed.host); + const keys = await scanHostKeys(parsed.host, parsed.port, address); res.json({ host: parsed.host, port: parsed.port, keys, }); } catch (error) { + if (error instanceof UnsafeOutboundTargetError) { + res.status(400).json({ + error: error.reason === 'blocked' + ? 'Repository host is not allowed' + : 'Repository host could not be resolved', + }); + return; + } sendGitSourceError(res, error); } }); @@ -194,7 +241,7 @@ gitSourcesRouter.get('/history', async (req: Request, res: Response): Promise => { if (!requirePermission(req, res, 'stack:create')) return; - await handleBrowse(req, res, null, null, null); + await handleBrowse(req, res, null, null, null, null); }); /** @@ -291,6 +338,8 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res deploy_key, ssh_known_hosts_entry, ssh_host_key_fingerprint, + ca_bundle, + remove_ca_bundle, auto_apply_on_webhook, auto_deploy_on_apply, } = req.body ?? {}; @@ -345,6 +394,18 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res res.status(400).json({ error: 'deploy_key is too long' }); return; } + if (typeof ca_bundle === 'string' && ca_bundle.length > MAX_CA_BUNDLE_LENGTH) { + res.status(400).json({ error: 'ca_bundle is too long' }); + return; + } + if (typeof ca_bundle === 'string' && ca_bundle.trim() && !validateCaBundlePem(ca_bundle)) { + res.status(400).json({ error: 'ca_bundle must contain one or more PEM certificates' }); + return; + } + if (remove_ca_bundle !== undefined && typeof remove_ca_bundle !== 'boolean') { + res.status(400).json({ error: 'remove_ca_bundle must be a boolean' }); + return; + } const autoApplyOnWebhook = auto_apply_on_webhook === true; const autoDeployOnApply = auto_deploy_on_apply === true; if (autoDeployOnApply && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; @@ -376,6 +437,8 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res deployKey: typeof deploy_key === 'string' ? deploy_key : undefined, sshKnownHostsEntry: typeof ssh_known_hosts_entry === 'string' ? ssh_known_hosts_entry : undefined, sshHostKeyFingerprint: typeof ssh_host_key_fingerprint === 'string' ? ssh_host_key_fingerprint : undefined, + caBundle: typeof ca_bundle === 'string' ? ca_bundle : undefined, + removeCaBundle: remove_ca_bundle === true, autoApplyOnWebhook, autoDeployOnApply, auditContext: { @@ -501,7 +564,7 @@ stackGitSourceRouter.post('/:stackName/git-source/apply', async (req: Request, r requirePlanFingerprint: true, }, ); - invalidateNodeCaches(req.nodeId); + // Cache invalidation and the post-deploy scan now run inside GitSourceService.apply() itself. const shortSha = commitSha.trim().slice(0, 7); if (result.deployed) { console.log('[GitSource] Applied commit %s to %s (deployed)', sanitizeForLog(shortSha), sanitizeForLog(stackName)); @@ -511,11 +574,6 @@ stackGitSourceRouter.post('/:stackName/git-source/apply', async (req: Request, r console.log('[GitSource] Applied commit %s to %s', sanitizeForLog(shortSha), sanitizeForLog(stackName)); } res.json(result); - if (result.deployed) { - triggerPostDeployScan(stackName, req.nodeId).catch(err => - console.error(`[Security] Post-deploy scan failed for ${sanitizeForLog(stackName)}:`, err), - ); - } } catch (error) { sendGitSourceError(res, error); } @@ -528,14 +586,28 @@ stackGitSourceRouter.post('/:stackName/git-source/webhook-pull', async (req: Req return; } if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + const deliveryId = req.body?.deliveryId; + if ( + deliveryId !== undefined + && (typeof deliveryId !== 'string' || !deliveryId.trim() || deliveryId.length > MAX_WEBHOOK_DELIVERY_ID_LENGTH) + ) { + res.status(400).json({ error: 'deliveryId must be a non-empty string of at most 512 characters' }); + return; + } try { - const source = GitSourceService.getInstance().get(stackName); + const service = GitSourceService.getInstance(); + const source = service.get(stackName); if (!source) { res.status(404).json({ error: 'No Git source configured for this stack', status: 'error' }); return; } - if (source.auto_apply_on_webhook && source.auto_deploy_on_apply && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; - const result = await GitSourceService.getInstance().handleWebhookPull(stackName); + const normalizedDeliveryId = deliveryId?.trim(); + const deployAuthorized = checkPermission(req, 'stack:deploy', 'stack', stackName); + if (service.webhookDeliveryRequiresDeploy(stackName, normalizedDeliveryId) && !deployAuthorized) { + requirePermission(req, res, 'stack:deploy', 'stack', stackName); + return; + } + const result = await service.handleWebhookPull(stackName, deployAuthorized, normalizedDeliveryId); // Map the outcome to a real HTTP status so a Git provider sees a 4xx on // failure instead of a 200 with an error body (which it would read as // "delivered fine, stop retrying"). @@ -560,6 +632,64 @@ stackGitSourceRouter.post('/:stackName/git-source/dismiss-pending', async (req: } }); +stackGitSourceRouter.post('/:stackName/git-source/suspend', async (req: Request, res: Response): Promise => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + const { reason: rawReason } = req.body ?? {}; + const reason = typeof rawReason === 'string' ? rawReason : undefined; + if (reason !== undefined && reason.length > MAX_SUSPEND_REASON_LENGTH) { + res.status(400).json({ error: 'reason is too long' }); + return; + } + try { + const result = await GitSourceService.getInstance().suspend(stackName, { + actor: req.user?.username ?? 'unknown', + reason, + }); + res.json(result); + } catch (error) { + sendGitSourceError(res, error); + } +}); + +stackGitSourceRouter.post('/:stackName/git-source/resume', async (req: Request, res: Response): Promise => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + try { + const result = await GitSourceService.getInstance().resume(stackName, { + actor: req.user?.username ?? 'unknown', + }); + res.json(result); + } catch (error) { + sendGitSourceError(res, error); + } +}); + +stackGitSourceRouter.post('/:stackName/git-source/retry', async (req: Request, res: Response): Promise => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + try { + const result = await GitSourceService.getInstance().retry(stackName, { + actor: req.user?.username ?? 'unknown', + }); + res.json(result); + } catch (error) { + sendGitSourceError(res, error); + } +}); + // Edit-mode repo browse for an existing stack: gated by stack:edit so a user who // can edit (but not create) stacks can re-pick files, and reuses the stored token // when the request omits one. @@ -574,5 +704,6 @@ stackGitSourceRouter.post('/:stackName/git-source/browse', async (req: Request, const storedToken = src?.encrypted_token ? CryptoService.getInstance().decrypt(src.encrypted_token) : null; const storedDeployKey = src?.encrypted_deploy_key ? CryptoService.getInstance().decrypt(src.encrypted_deploy_key) : null; const storedKnownHosts = src?.ssh_known_hosts_entry ?? null; - await handleBrowse(req, res, storedToken, storedDeployKey, storedKnownHosts); + const storedCaBundle = src?.encrypted_ca_bundle ? CryptoService.getInstance().decrypt(src.encrypted_ca_bundle) : null; + await handleBrowse(req, res, storedToken, storedDeployKey, storedKnownHosts, storedCaBundle); }); diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index 00cdb216..24de5eeb 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -4,6 +4,7 @@ import { CronExpressionParser } from 'cron-parser'; import DockerController from '../services/DockerController'; import { DatabaseService } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; +import { safeRemoteFetch } from '../utils/outboundTarget'; import { CacheService } from '../services/CacheService'; import { createAutoUpdateDigestGateState, @@ -274,16 +275,20 @@ imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Respo const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REMOTE_NODE_FETCH_TIMEOUT_MS); try { - const resp = await fetch(`${baseUrl}/api/image-updates`, { + const resp = await safeRemoteFetch(`${baseUrl}/api/image-updates`, { headers: proxyTarget.apiToken ? { Authorization: `Bearer ${proxyTarget.apiToken}` } : {}, signal: controller.signal, - }); + }, proxyTarget.trustedLoopback); clearTimeout(timeout); if (resp.ok) return { nodeId: node.id, data: await resp.json() as Record }; - } catch { + } catch (error: unknown) { clearTimeout(timeout); + console.warn( + `[Image updates] Status fetch failed for node "${sanitizeForLog(node.name)}":`, + getErrorMessage(error, 'unknown'), + ); } return null; }), @@ -348,17 +353,21 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request, const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REMOTE_NODE_FETCH_TIMEOUT_MS); try { - const resp = await fetch(`${baseUrl}/api/image-updates/refresh`, { + const resp = await safeRemoteFetch(`${baseUrl}/api/image-updates/refresh`, { method: 'POST', headers: proxyTarget.apiToken ? { Authorization: `Bearer ${proxyTarget.apiToken}` } : {}, signal: controller.signal, - }); + }, proxyTarget.trustedLoopback); clearTimeout(timeout); return { nodeId: node.id, status: resp.status }; } catch (e) { clearTimeout(timeout); + console.warn( + `[Image updates] Refresh failed for node "${sanitizeForLog(node.name)}":`, + getErrorMessage(e, 'unknown'), + ); return { nodeId: node.id, status: 0, error: e }; } }), diff --git a/backend/src/routes/meta.ts b/backend/src/routes/meta.ts index 93dcba01..3e6d8402 100644 --- a/backend/src/routes/meta.ts +++ b/backend/src/routes/meta.ts @@ -1,9 +1,10 @@ import { Router, type Request, type Response } from 'express'; import { getActiveCapabilities, getSenchoVersion } from '../services/CapabilityRegistry'; import { classifyImageChannel } from '../helpers/imageChannel'; -import { isRepinBlocked } from '../helpers/selfUpdateCompose'; +import { classifyBuildChannel, isRepinBlocked } from '../helpers/selfUpdateCompose'; import { MeshService } from '../services/MeshService'; import SelfUpdateService from '../services/SelfUpdateService'; +import SelfIdentityService from '../services/SelfIdentityService'; // Captured at boot. Exposed via /api/health and /api/meta so the Fleet update // overlay can distinguish a brand-new process from the old one still mid-pull. @@ -41,6 +42,7 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise => { const updateError = selfUpdate.getLastError(); const pin = await selfUpdate.getPinInfo({ cacheOnly: true }); const updateBlocked = pin ? isRepinBlocked(pin.pinKind) : false; + const runningRef = SelfIdentityService.getInstance().getBuildInfo().imageRef; res.json({ version: getSenchoVersion(), capabilities: getActiveCapabilities(), @@ -50,6 +52,10 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise => { imagePinKind: pin.pinKind, imageChannel: classifyImageChannel(pin.composeImageRef), } : {}), + // Bounded build channel of the RUNNING image (stable|dev|preview|unknown). + // Like imagePinKind, this is a non-sensitive enum; no image reference is + // ever exposed on this public endpoint. + ...(runningRef ? { buildChannel: classifyBuildChannel(runningRef) } : {}), updateBlocked, ...(updateError ? { updateError: 'update_failed' } : {}), }); diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index 2697a9e6..22232827 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -27,6 +27,7 @@ import { logDebugTiming } from '../utils/requestTiming'; import { BlueprintReconciler } from '../services/BlueprintReconciler'; import { recordPlacementShift, snapshotPlacementWith } from '../services/gitops/nodePlacementProducers'; import { projectCommittedRevisions } from '../helpers/gitopsResponse'; +import { assertSafeOutboundUrl, safeRemoteFetch, UnsafeOutboundTargetError } from '../utils/outboundTarget'; const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.'; const REMOTE_META_CACHE_TTL = 3 * 60 * 1000; @@ -59,9 +60,7 @@ function resolvePrimaryUrl(req: Request): string { if (check.valid) return override.replace(/\/$/, ''); console.warn(`[Enrollment] SENCHO_PUBLIC_URL is set but invalid (${check.reason}); falling back to request host.`); } - const forwardedProto = req.headers['x-forwarded-proto']; - const protoHeader = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; - const protocol = protoHeader || req.protocol || 'http'; + const protocol = req.protocol; const host = req.get('host') || 'localhost:1852'; return `${protocol}://${host}`; } @@ -251,6 +250,17 @@ nodesRouter.post('/', enrollmentLimiter, async (req: Request, res: Response) => if (!urlCheck.valid) { return res.status(400).json({ error: urlCheck.reason }); } + try { + await assertSafeOutboundUrl(api_url); + } catch (error: unknown) { + if (error instanceof UnsafeOutboundTargetError) { + const message = error.reason === 'blocked' + ? 'API URL target is not allowed' + : 'API URL host could not be resolved'; + return res.status(400).json({ error: message }); + } + throw error; + } } const id = DatabaseService.getInstance().addNode({ @@ -381,6 +391,17 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => { if (!urlCheck.valid) { return res.status(400).json({ error: urlCheck.reason }); } + try { + await assertSafeOutboundUrl(updates.api_url); + } catch (error: unknown) { + if (error instanceof UnsafeOutboundTargetError) { + const message = error.reason === 'blocked' + ? 'API URL target is not allowed' + : 'API URL host could not be resolved'; + return res.status(400).json({ error: message }); + } + throw error; + } } DatabaseService.getInstance().updateNode(id, updates); @@ -590,7 +611,7 @@ nodesRouter.post('/:id/fleet-sync/reset-anchor', async (req: Request, res: Respo const baseUrl = node.api_url.replace(/\/$/, ''); let peerResponse: globalThis.Response; try { - peerResponse = await fetch(`${baseUrl}/api/fleet/role/reanchor`, { + peerResponse = await safeRemoteFetch(`${baseUrl}/api/fleet/role/reanchor`, { method: 'POST', headers: { Authorization: `Bearer ${node.api_token}`, diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index e4deba6d..478be703 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -25,6 +25,7 @@ import { import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService'; import { repoUrlRejectionMessage } from '../services/gitops/repoIdentity'; import { REF_MAX_LEN } from '../services/git/nativeGitTransport'; +import { validateCaBundlePem } from '../services/git/caBundle'; import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { getRegistryDeliveryContext, getRegistryDeliveryLockContext } from '../helpers/registryDeliveryContext'; import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService'; @@ -1096,6 +1097,7 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { deploy_key, ssh_known_hosts_entry, ssh_host_key_fingerprint, + ca_bundle, auto_apply_on_webhook, auto_deploy_on_apply, deploy_now, @@ -1142,6 +1144,12 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { if (typeof token === 'string' && token.length > 8192) { return res.status(400).json({ error: 'token is too long' }); } + if (typeof ca_bundle === 'string' && ca_bundle.length > 65536) { + return res.status(400).json({ error: 'ca_bundle is too long' }); + } + if (typeof ca_bundle === 'string' && ca_bundle.trim() && !validateCaBundlePem(ca_bundle)) { + return res.status(400).json({ error: 'ca_bundle must contain one or more PEM certificates' }); + } if (typeof env_path === 'string' && env_path.trim() && !isValidGitSourcePath(env_path.trim())) { return res.status(400).json({ error: 'env_path must be a relative repository file path' }); } @@ -1183,6 +1191,7 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { sshHostKeyFingerprint: resolvedAuthType === 'deploy_key' && typeof ssh_host_key_fingerprint === 'string' ? ssh_host_key_fingerprint : null, + caBundle: typeof ca_bundle === 'string' ? ca_bundle : null, autoApplyOnWebhook, autoDeployOnApply, auditContext: { diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index f9b97d62..42f32e3c 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -12,6 +12,34 @@ function isWebhookAction(value: unknown): value is WebhookAction { return typeof value === 'string' && (VALID_WEBHOOK_ACTIONS as readonly string[]).includes(value); } +// Recognized per-delivery identity headers, in priority order. This +// endpoint is a generic HMAC-signed trigger, not a provider-specific +// receiver, so a well-known provider header is the only delivery identity +// available, and only when the caller happens to send one. The value is a +// stable delivery identity available. WebhookService namespaces the value by +// control instance and configured webhook before it reaches GitSourceService, +// so two producers cannot collide on the same provider-assigned id. +// +// Each header is still the provider's actual per-delivery identity rather +// than a webhook- or connection-level id that stays constant across every +// delivery from that source: GitHub's X-GitHub-Delivery GUID changes per +// delivery (it is stable only across redeliveries of the same delivery); +// GitLab's is Webhook-ID, the modern name for its Idempotency-Key, not +// X-Gitlab-Event-UUID, which tracks recursive-trigger chains and can +// repeat across genuinely distinct events; Bitbucket's is X-Request-UUID, +// not X-Hook-UUID, which identifies the webhook configuration itself. +// Picking the wrong one would deduplicate genuinely distinct pushes. +const DELIVERY_ID_HEADERS = ['x-github-delivery', 'webhook-id', 'idempotency-key', 'x-request-uuid', 'x-webhook-delivery-id'] as const; + +function deliveryIdFromHeaders(headers: Request['headers']): string | undefined { + for (const name of DELIVERY_ID_HEADERS) { + const value = headers[name]; + const first = Array.isArray(value) ? value[0] : value; + if (first) return first; + } + return undefined; +} + export const webhooksRouter = Router(); webhooksRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { @@ -193,6 +221,7 @@ webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request, action = overrideAction; } const triggerSource = req.headers['user-agent'] || req.ip || null; + const deliveryId = deliveryIdFromHeaders(req.headers); // Execute asynchronously; return 202 immediately. res.status(202).json({ message: 'Webhook accepted', action }); @@ -202,7 +231,7 @@ webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request, // dispatch the action still completes and recordExecution swallows the // FK error from the CASCADE. atomic is unconditionally true, so the // deploy/pull paths always run in atomic mode here. - svc.execute(webhook, action, triggerSource, true).catch(err => { + svc.execute(webhook, action, triggerSource, true, deliveryId).catch(err => { console.error(`[Webhooks] Execution error for webhook ${id}:`, err); }); } catch (error) { diff --git a/backend/src/services/AutoHealService.ts b/backend/src/services/AutoHealService.ts index fe7f1abb..6a80f2cb 100644 --- a/backend/src/services/AutoHealService.ts +++ b/backend/src/services/AutoHealService.ts @@ -9,6 +9,7 @@ import { NotificationService } from './NotificationService'; import { PROXY_TIER_HEADER } from './license-headers'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; +import { safeRemoteFetch } from '../utils/outboundTarget'; // Dockerode listContainers shape (subset used here) type ContainerInfo = { @@ -177,14 +178,14 @@ export class AutoHealService { } const baseUrl = target.apiUrl.replace(/\/$/, ''); try { - const res = await fetch(`${baseUrl}/api/auto-heal/policies`, { + const res = await safeRemoteFetch(`${baseUrl}/api/auto-heal/policies`, { method: 'GET', headers: { 'Authorization': `Bearer ${target.apiToken}`, [PROXY_TIER_HEADER]: proxyHeaders.tier, }, signal: AbortSignal.timeout(LEASE_REFRESH_TIMEOUT_MS), - }); + }, target.trustedLoopback); if (res.ok) { this.leaseRefreshFailures.delete(nodeId); } else { diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts index 59c06610..81dfea94 100644 --- a/backend/src/services/BlueprintService.ts +++ b/backend/src/services/BlueprintService.ts @@ -13,6 +13,7 @@ import { StackOpLockService, stackOpSkipMessage, type StackOpAction } from './St import { DeployedStackDeletionService } from './DeployedStackDeletionService'; import { FileSystemService } from './FileSystemService'; import { NodeRegistry } from './NodeRegistry'; +import { safeAxiosTransport } from '../utils/outboundTarget'; import { PROXY_TIER_HEADER, deployProvenanceHeaders } from './license-headers'; import { LicenseService } from './LicenseService'; import { assertPolicyGateAllows, buildSystemPolicyGateOptions, describePolicyBlock, triggerPostDeployScan } from '../helpers/policyGate'; @@ -180,6 +181,7 @@ export class BlueprintService { if (!target) return null; const url = `${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(blueprintName)}/files/content?path=${encodeURIComponent(BLUEPRINT_MARKER_FILENAME)}`; const res = await axios.get(url, { + ...safeAxiosTransport(target.trustedLoopback), headers: this.remoteHeaders(target.apiToken), timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true, @@ -229,6 +231,7 @@ export class BlueprintService { let listRes; try { listRes = await axios.get(`${baseUrl}/api/stacks`, { + ...safeAxiosTransport(target.trustedLoopback), headers: this.remoteHeaders(target.apiToken), timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true, @@ -433,6 +436,7 @@ export class BlueprintService { if (!target) return { allRunning: false, detail: 'remote node not reachable (no proxy target)' }; const url = `${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(blueprintName)}/containers`; const res = await axios.get(url, { + ...safeAxiosTransport(target.trustedLoopback), headers: this.remoteHeaders(target.apiToken), timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true, @@ -659,7 +663,12 @@ export class BlueprintService { const res = await axios.post( `${baseUrl}/api/blueprints/apply-local`, augmented.body, - { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, + { + ...safeAxiosTransport(target.trustedLoopback), + headers, + timeout: REMOTE_HTTP_TIMEOUT_MS, + validateStatus: () => true, + }, ); if (res.status === 404) { throw new BlueprintRemoteUpgradeRequiredError( @@ -691,7 +700,12 @@ export class BlueprintService { res = await axios.post( `${baseUrl}/api/blueprints/withdraw-local`, { stackName: blueprint.name, blueprintId: blueprint.id }, - { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, + { + ...safeAxiosTransport(target.trustedLoopback), + headers, + timeout: REMOTE_HTTP_TIMEOUT_MS, + validateStatus: () => true, + }, ); } catch (err) { const message = BlueprintService.formatError(err); diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index cf541f51..b5c3ec23 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -5,6 +5,7 @@ import semver from 'semver'; import { SENCHO_VERSION } from '../generated/version'; import { isDebugEnabled } from '../utils/debug'; import type { ImagePinKind } from '../helpers/selfUpdateCompose'; +import { assertSafeOutboundUrl, safeAxiosTransport } from '../utils/outboundTarget'; const IMAGE_PIN_KINDS: readonly ImagePinKind[] = ['floating', 'semver', 'digest', 'unknown']; @@ -239,10 +240,16 @@ function redactUrlCredentials(url: string): string { } /** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */ -export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise { +export async function fetchRemoteMeta( + baseUrl: string, + apiToken: string, + trustedLoopback = false, +): Promise { const safeUrl = redactUrlCredentials(baseUrl); try { + if (!trustedLoopback) await assertSafeOutboundUrl(baseUrl); const res = await axios.get(`${baseUrl.replace(/\/$/, '')}/api/meta`, { + ...safeAxiosTransport(trustedLoopback), headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {}, timeout: 5000, }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 881bc1de..c406e6b0 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -461,6 +461,7 @@ export interface StackGitSource { encrypted_deploy_key: string | null; ssh_known_hosts_entry: string | null; ssh_host_key_fingerprint: string | null; + encrypted_ca_bundle: string | null; auto_apply_on_webhook: boolean; auto_deploy_on_apply: boolean; last_applied_commit_sha: string | null; @@ -572,6 +573,14 @@ export interface NotificationHistory { container_name?: string; actor_username?: string | null; suppression_match?: string | null; + /** The GitOps operation this notification reports on, if any. */ + gitops_operation_id?: string | null; + /** + * Unique across all notifications when set. Lets fanout repair re-run + * safely: inserting the same key again is a no-op that returns the + * existing row instead of creating a duplicate. + */ + dedupe_key?: string | null; } export interface FleetSnapshot { @@ -1163,6 +1172,7 @@ export class DatabaseService { this.migratePolicyEvaluationColumn(); this.migrateNotificationCategory(); this.migrateNotificationActor(); + this.migrateNotificationGitOpsDedupe(); this.migrateMeshTables(); this.migrateNodeLabels(); this.migrateBlueprints(); @@ -1175,6 +1185,7 @@ export class DatabaseService { this.migrateStackDossierHashes(); this.migrateGitSourceMultiFile(); this.migrateGitSourceSshDeployKey(); + this.migrateGitSourcePrivateCa(); this.migrateGitSourceManifest(); this.migrateGitSourceChangePlan(); this.migrateGitOpsRecoveryColumns(); @@ -1961,6 +1972,28 @@ export class DatabaseService { // from the CREATE TABLE; older DBs need the additive column here. maybeAddCol('gitops_generations', 'resolved_ref_kind', 'TEXT NULL'); maybeAddCol('gitops_applications', 'fetched_resolved_ref_kind', 'TEXT NULL'); + // Source suspension reason, distinct from the rollout pause_reason + // existing installs already have. New installs get it from the + // CREATE TABLE; older DBs need the additive column here. + maybeAddCol('gitops_applications', 'source_suspended_reason', 'TEXT NULL'); + // Portable accepted-generation contract fields. Additive and + // nullable: existing generation rows decode these as an explicit + // limitation rather than invented evidence. + maybeAddCol('gitops_generations', 'portable_manifest_json', 'TEXT NULL'); + maybeAddCol('gitops_generations', 'compose_inputs_json', 'TEXT NULL'); + maybeAddCol('gitops_generations', 'source_policy_evidence_json', 'TEXT NULL'); + maybeAddCol('gitops_generations', 'security_policy_evidence_json', 'TEXT NULL'); + maybeAddCol('gitops_generations', 'support_requirements_json', 'TEXT NULL'); + maybeAddCol('gitops_generations', 'compatibility_requirements_json', 'TEXT NULL'); + // Controller-owned bookkeeping (source policy, poll cadence, attempt + // sequence). New installs get these from the CREATE TABLE; older DBs + // need the additive columns here. Existing installations must not + // start unattended polling, so poll_interval_secs and next_poll_at + // stay NULL until an operator (or the migration below) sets one. + maybeAddCol('gitops_applications', 'source_policy', "TEXT NOT NULL DEFAULT 'manual' CHECK (source_policy IN ('manual','review','automatic'))"); + maybeAddCol('gitops_applications', 'poll_interval_secs', 'INTEGER NULL'); + maybeAddCol('gitops_applications', 'next_poll_at', 'INTEGER NULL'); + maybeAddCol('gitops_applications', 'attempt_seq', 'INTEGER NOT NULL DEFAULT 0'); // Distributed API model columns maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''"); @@ -2643,6 +2676,11 @@ stmt.run('gitops_schema_version', '1'); this.tryAddColumn('stack_git_sources', 'ssh_host_key_fingerprint', 'TEXT'); } + private migrateGitSourcePrivateCa(): void { + this.tryAddColumn('stack_git_sources', 'encrypted_ca_bundle', 'TEXT'); + this.tryAddColumn('gitops_create_checkpoints', 'encrypted_ca_bundle', 'TEXT'); + } + private migrateGitSourceManifest(): void { // Cache columns for the managed-project manifest (the manifest FILE in // /git-managed/// is the source of truth). @@ -2792,6 +2830,29 @@ stmt.run('gitops_schema_version', '1'); } } + /** + * A GitOps history/operation reference and a dedupe key, so notification + * fanout for a settled GitOps attempt can be repaired from durable state + * (retried at startup after a crash between commit and fanout) without + * ever inserting a duplicate notification for the same attempt. + */ + private migrateNotificationGitOpsDedupe(): void { + this.tryAddColumn('notification_history', 'gitops_operation_id', 'TEXT'); + this.tryAddColumn('notification_history', 'dedupe_key', 'TEXT'); + try { + this.db.prepare( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_notif_history_dedupe_key ON notification_history(dedupe_key) WHERE dedupe_key IS NOT NULL' + ).run(); + } catch (err) { + // Unlike a pure performance index, this one is the ON CONFLICT + // target every addNotificationHistory() insert names. If it is + // missing, every notification write in the product fails, not + // just GitOps ones, so a silent catch here would turn into an + // unexplained total outage instead of a diagnosable startup log. + console.error('[DatabaseService] Failed to create notification dedupe index:', err); + } + } + private migrateMeshTables(): void { try { if (isPilotMode()) { @@ -4731,8 +4792,13 @@ stmt.run('gitops_schema_version', '1'); } public addNotificationHistory(nodeId: number, notification: Omit): NotificationHistory { + const dedupeKey = notification.dedupe_key ?? null; const stmt = this.db.prepare( - 'INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, container_name, category, actor_username) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)' + `INSERT INTO notification_history ( + node_id, level, message, timestamp, is_read, stack_name, container_name, + category, actor_username, gitops_operation_id, dedupe_key + ) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) + ON CONFLICT(dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING` ); const result = stmt.run( nodeId, @@ -4743,8 +4809,20 @@ stmt.run('gitops_schema_version', '1'); notification.container_name ?? null, notification.category ?? null, notification.actor_username ?? null, + notification.gitops_operation_id ?? null, + dedupeKey, ); + // A repair replaying a settled GitOps attempt must not create a + // duplicate notification; the conflict is not an error, it is proof + // this exact attempt was already reported. + if (result.changes === 0 && dedupeKey !== null) { + const existing = this.db.prepare( + 'SELECT * FROM notification_history WHERE dedupe_key = ?', + ).get(dedupeKey); + return this.mapNotificationRow(existing); + } + return { id: result.lastInsertRowid as number, level: notification.level, @@ -4755,6 +4833,8 @@ stmt.run('gitops_schema_version', '1'); stack_name: notification.stack_name, container_name: notification.container_name, actor_username: notification.actor_username, + gitops_operation_id: notification.gitops_operation_id, + dedupe_key: dedupeKey, }; } @@ -6496,6 +6576,7 @@ stmt.run('gitops_schema_version', '1'); encrypted_deploy_key: (row.encrypted_deploy_key as string | null) ?? null, ssh_known_hosts_entry: (row.ssh_known_hosts_entry as string | null) ?? null, ssh_host_key_fingerprint: (row.ssh_host_key_fingerprint as string | null) ?? null, + encrypted_ca_bundle: (row.encrypted_ca_bundle as string | null) ?? null, auto_apply_on_webhook: Number(row.auto_apply_on_webhook) === 1, auto_deploy_on_apply: Number(row.auto_deploy_on_apply) === 1, last_applied_commit_sha: (row.last_applied_commit_sha as string | null) ?? null, @@ -6538,6 +6619,7 @@ stmt.run('gitops_schema_version', '1'); sync_env = ?, env_path = ?, auth_type = ?, encrypted_token = ?, encrypted_deploy_key = ?, ssh_known_hosts_entry = ?, ssh_host_key_fingerprint = ?, + encrypted_ca_bundle = ?, auto_apply_on_webhook = ?, auto_deploy_on_apply = ?, updated_at = ? WHERE stack_name = ?` @@ -6546,6 +6628,7 @@ stmt.run('gitops_schema_version', '1'); source.sync_env ? 1 : 0, source.env_path, source.auth_type, source.encrypted_token, source.encrypted_deploy_key, source.ssh_known_hosts_entry, source.ssh_host_key_fingerprint, + source.encrypted_ca_bundle, source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0, now, source.stack_name ); @@ -6555,14 +6638,16 @@ stmt.run('gitops_schema_version', '1'); `INSERT INTO stack_git_sources (stack_name, repo_url, branch, compose_path, compose_paths, context_dir, sync_env, env_path, auth_type, encrypted_token, encrypted_deploy_key, ssh_known_hosts_entry, ssh_host_key_fingerprint, + encrypted_ca_bundle, auto_apply_on_webhook, auto_deploy_on_apply, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( source.stack_name, source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir, source.sync_env ? 1 : 0, source.env_path, source.auth_type, source.encrypted_token, source.encrypted_deploy_key, source.ssh_known_hosts_entry, source.ssh_host_key_fingerprint, + source.encrypted_ca_bundle, source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0, now, now ); diff --git a/backend/src/services/FleetSyncService.ts b/backend/src/services/FleetSyncService.ts index 27d9180d..80f5ee99 100644 --- a/backend/src/services/FleetSyncService.ts +++ b/backend/src/services/FleetSyncService.ts @@ -5,6 +5,7 @@ import { NodeRegistry } from './NodeRegistry'; import { NotificationService } from './NotificationService'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; +import { safeAxiosTransport } from '../utils/outboundTarget'; import { FleetResource, MAX_SYNC_ROWS, @@ -500,6 +501,7 @@ export class FleetSyncService { `${baseUrl}/api/fleet/sync/${resource}`, payload, { + ...safeAxiosTransport(false), headers: { Authorization: `Bearer ${node.api_token}` }, timeout: 15_000, }, diff --git a/backend/src/services/GitProjectManifestService.ts b/backend/src/services/GitProjectManifestService.ts index df4138eb..e38f8d1e 100644 --- a/backend/src/services/GitProjectManifestService.ts +++ b/backend/src/services/GitProjectManifestService.ts @@ -51,6 +51,10 @@ export const MANAGED_ROOT_NAME = 'git-managed'; export const MANIFEST_FILENAME = 'manifest.v1.json'; export const PROMOTION_MARKER = 'promotion.json'; export const CANDIDATE_COMPLETE_MARKER = '.candidate-complete'; + +type CandidateClaims = + | { complete: true; dirs: ReadonlySet } + | { complete: false }; export const GENERATIONS_DIR = 'generations'; const DETACH_RECOVERY_MARKER = 'detach-recovery.v1.json'; @@ -421,10 +425,19 @@ export class GitProjectManifestService { /** Atomic manifest write (tmp + rename). */ async writeManifest(stackName: string, manifest: GitProjectManifest): Promise { - const dir = this.managedRoot(stackName); - await fs.promises.mkdir(dir, { recursive: true }); - const target = path.join(dir, MANIFEST_FILENAME); - const tmp = path.join(dir, `${MANIFEST_FILENAME}.tmp`); + // Inline barrier at the mkdir/write/rename sinks (CodeQL path-injection): + // confine the resolved managed directory to the managed area before any + // filesystem call touches it, then confine the filenames joined onto it. + const root = path.resolve(this.managedRoot(stackName)); + if (!root.startsWith(managedAreaBase() + path.sep)) { + throw Object.assign(new Error('Path escapes the managed area'), { code: 'INVALID_PATH' }); + } + const target = path.resolve(root, MANIFEST_FILENAME); + const tmp = path.resolve(root, `${MANIFEST_FILENAME}.tmp`); + if (!target.startsWith(root + path.sep) || !tmp.startsWith(root + path.sep)) { + throw Object.assign(new Error('Path escapes managed project directory'), { code: 'INVALID_PATH' }); + } + await fs.promises.mkdir(root, { recursive: true }); await fs.promises.writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf8'); await fs.promises.rename(tmp, target); } @@ -1268,12 +1281,20 @@ export class GitProjectManifestService { * is finalized; an uncommitted promotion restores the prior generation. * A third state is treated as an operator edit, so recovery declines and * flags migration_required. Interrupted detach snapshots are restored first. + * Complete candidate claims hold the directory basenames that durable state + * still references. Incomplete claims preserve every candidate because + * ownership is uncertain. */ async sweepManagedArea( stackName: string, - opts: { repoUrl: string; branch: string; stackExists: boolean }, + opts: { + repoUrl: string; + branch: string; + stackExists: boolean; + candidateClaims: CandidateClaims; + }, ): Promise { - const { repoUrl, branch, stackExists } = opts; + const { repoUrl, branch, stackExists, candidateClaims } = opts; if (!stackExists) { await this.deleteManagedArea(stackName); return; @@ -1359,44 +1380,64 @@ export class GitProjectManifestService { } } - // Orphan candidates: incomplete or stale. + if (!candidateClaims.complete) { + await this.flagRecoveryRequired( + stackName, + `candidate ownership for ${sanitizeForLog(stackName)} could not be established`, + ); + return; + } + + // With a complete claim inventory, reap candidates that are incomplete + // or stale and unclaimed. const dir = this.generationsDir(stackName); + let entries: fs.Dirent[]; try { - const entries = await fs.promises.readdir(dir, { withFileTypes: true }); - const now = Date.now(); - const areaBase = managedAreaBase(); - for (const entry of entries) { - if (!entry.isDirectory() || !entry.name.startsWith('candidate-')) continue; - const abs = path.resolve(dir, entry.name); - // Inline containment barrier at the removal sink (see - // `managedAreaBase`): the analyzer credits this literal - // comparison, not the positional check below it. - if (!abs.startsWith(areaBase + path.sep)) { - console.warn(`[GitManifest] refusing to reap orphan candidate ${sanitizeForLog(entry.name)} for ${sanitizeForLog(stackName)}: it resolves outside the managed area`); - continue; - } - // Same positional barrier as generation pruning: the boot sweep - // reaps candidate directories nobody claims, which is precisely - // the kind of unattended delete a planted link would steer. - if (!await isRealPathAtManagedLocation(abs)) { - console.warn(`[GitManifest] refusing to reap orphan candidate ${sanitizeForLog(entry.name)} for ${sanitizeForLog(stackName)}: it is not at its own location in the managed area`); - continue; - } - const complete = await fs.promises - .access(path.join(abs, CANDIDATE_COMPLETE_MARKER)) - .then(() => true) - .catch(() => false); - if (!complete) { - await fs.promises.rm(abs, { recursive: true, force: true }); - continue; - } - const st = await fs.promises.stat(abs); - if (now - st.mtimeMs > ORPHAN_CANDIDATE_AGE_MS) { - await fs.promises.rm(abs, { recursive: true, force: true }); - } + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return; + throw e; + } + const now = Date.now(); + const areaBase = managedAreaBase(); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('candidate-')) continue; + // A claimed candidate is still needed regardless of age or + // completeness. Claims come from application pointers, the + // source pending record, and generation rows linked to + // unsettled attempts. Together they are the durable ownership + // record that makes recovery before cleanup safe. + if (candidateClaims.dirs.has(entry.name)) continue; + const abs = path.resolve(dir, entry.name); + // Inline containment barrier at the removal sink (see + // `managedAreaBase`): the analyzer credits this literal + // comparison, not the positional check below it. + if (!abs.startsWith(areaBase + path.sep)) { + console.warn(`[GitManifest] refusing to reap orphan candidate ${sanitizeForLog(entry.name)} for ${sanitizeForLog(stackName)}: it resolves outside the managed area`); + continue; + } + // Same positional barrier as generation pruning: the boot sweep + // reaps candidate directories nobody claims, which is precisely + // the kind of unattended delete a planted link would steer. + if (!await isRealPathAtManagedLocation(abs)) { + console.warn(`[GitManifest] refusing to reap orphan candidate ${sanitizeForLog(entry.name)} for ${sanitizeForLog(stackName)}: it is not at its own location in the managed area`); + continue; + } + let complete = true; + try { + await fs.promises.access(path.join(abs, CANDIDATE_COMPLETE_MARKER)); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + complete = false; + } + if (!complete) { + await fs.promises.rm(abs, { recursive: true, force: true }); + continue; + } + const st = await fs.promises.stat(abs); + if (now - st.mtimeMs > ORPHAN_CANDIDATE_AGE_MS) { + await fs.promises.rm(abs, { recursive: true, force: true }); } - } catch { - // no generations dir yet } } diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index 13f635a2..07e23eca 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -11,7 +11,8 @@ import { ComposeService } from './ComposeService'; import { StackOpLockService } from './StackOpLockService'; import { HealthGateService } from './HealthGateService'; import { NodeRegistry } from './NodeRegistry'; -import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate'; +import { assertPolicyGateAllows, buildSystemPolicyGateOptions, triggerPostDeployScan } from '../helpers/policyGate'; +import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; import { isPathWithinBase, isValidRelativeStackPath } from '../utils/validation'; @@ -27,12 +28,25 @@ import type { ComposeInputEntry, GitProjectManifest, GitSourceManifestState, Inv import type { GitChangePlan, PublicGitChangePlan, GitChangePlanCounts, PublicGitChangePlanOperation } from '../types/gitChangePlan'; import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan'; import type { NotificationCategory } from './NotificationService'; -import { classifyGitFailure, isTransportFailure } from './git/errors'; +import { classifyGitFailure, isTransportFailure, type TransportFailureReason } from './git/errors'; import type { RefKind, SshDeployKeyAuth } from './git/types'; import { nativeGitTransport, verifyFastForward } from './git/nativeGitTransport'; import { fingerprintFromKnownHostsLine } from './git/sshTrust'; +import { validateCaBundlePem } from './git/caBundle'; import { GitOpsStore } from './gitops/store'; -import { GitOpsTransitions, GitOpsTransitionError } from './gitops/transitions'; +import type { GitOpsHistoryCursor } from './gitops/history'; +import { projectApplication } from './gitops/derive'; +import { outcomeFromSourceFacet, isNextAction, isReconcileOutcome, type ReconcileOutcome, type ReconcileResult } from './gitops/outcomes'; +import { coalesceKey, deliveryKey, type ReconcileRequest, type ReconcileTrigger } from './gitops/triggers'; +import { classifyFailure } from './gitops/backoff'; +import { BlueprintTargetAdapter, type AcceptedGeneration, type DispatchContext, type DispatchResult } from './gitops/handoff'; +import { + GitOpsTransitions, + GitOpsTransitionError, + type EventEnvelope, + type ReconcileDeliveryIntent, +} from './gitops/transitions'; +import { decodeGitOpsJson, isRecord, GitOpsJsonError } from './gitops/json'; import { buildCreateCheckpointRow, buildDirectApplicationRow, @@ -41,7 +55,7 @@ import { newGitOpsId, stackManagedRoot, } from './gitops/directApplication'; -import type { GitOpsApplicationRow } from './gitops/types'; +import type { GitOpsApplicationRow, GitOpsHistoryRow } from './gitops/types'; import { appliedRelPathFor, candidateRelPathForSha, deleteStagingMarker, readStagingMarker, validateCandidateRelPath, writeStagingMarker } from './gitops/createStagingMarker'; import { cleanupUnclaimedManagedRoot, removeOperationOwnedPaths } from './gitops/createCleanup'; import { managedAreaBase } from './gitops/managedPaths'; @@ -67,6 +81,7 @@ export type GitSourceErrorCode = | 'UNSUPPORTED_REF' | 'SSH_HOST_KEY_FAILED' | 'FILE_NOT_FOUND' + | 'RATE_LIMITED' | 'NETWORK_TIMEOUT' | 'GIT_ERROR' | 'STALE_PLAN' @@ -80,7 +95,18 @@ export class GitSourceError extends Error { constructor( public code: GitSourceErrorCode, message: string, - public extras?: { plan?: PublicGitChangePlan; planFingerprint?: string }, + public extras?: { + plan?: PublicGitChangePlan; + planFingerprint?: string; + /** + * The raw structured reason from the native transport failure, + * kept alongside the sanitized `code`/`message` operators see. + * Consumed by GitOps retry/backoff classification, which needs + * more than the public error code to tell a transient network + * condition from a permanent configuration one. + */ + transportReason?: TransportFailureReason; + }, ) { super(message); this.name = 'GitSourceError'; @@ -112,6 +138,7 @@ export interface FetchParams { envPath?: string | null; token?: string | null; sshAuth?: SshDeployKeyAuth | null; + caBundlePem?: string | null; timeoutMs?: number; /** * Runs inside the clone lifecycle (before the temp dir is removed) so the @@ -173,6 +200,8 @@ export interface UpsertInput { deployKey?: string | null; sshKnownHostsEntry?: string | null; sshHostKeyFingerprint?: string | null; + caBundle?: string | null; // undefined = keep existing, '' = clear, non-empty = replace + removeCaBundle?: boolean; // explicit user-initiated revocation; overrides caBundle omission autoApplyOnWebhook: boolean; autoDeployOnApply: boolean; auditContext?: { @@ -196,6 +225,7 @@ export interface CreateStackFromGitInput { deployKey?: string | null; sshKnownHostsEntry?: string | null; sshHostKeyFingerprint?: string | null; + caBundle?: string | null; autoApplyOnWebhook: boolean; autoDeployOnApply: boolean; auditContext?: { @@ -248,6 +278,7 @@ export interface PublicGitSource { auth_type: GitSourceAuthType; has_token: boolean; has_deploy_key: boolean; + has_ca_bundle: boolean; ssh_host_key_fingerprint: string | null; auto_apply_on_webhook: boolean; auto_deploy_on_apply: boolean; @@ -272,6 +303,37 @@ export interface GitApplyOpts { requirePlanFingerprint?: boolean; } +type GitApplyResult = { + applied: boolean; + deployed: boolean; + deployError?: string; + recoveryId?: string; +}; + +type WorkOutcome = + | { status: 'fulfilled'; value: T } + | { status: 'rejected'; reason: unknown }; + +type SharedExecution = WorkOutcome & { result: ReconcileResult }; + +type LeaderCompletion = { + execution: SharedExecution; + settled: boolean; +}; + +/** + * In-process executions keyed by a request-derived execution key: the + * operation id each reservation minted, and the promise a later joiner awaits + * instead of repeating the work. + */ +type InFlightMap = Map> }>; + +type ExecutionSubmission = + | { kind: 'executed'; execution: SharedExecution } + | { kind: 'replayed'; result: ReconcileResult }; + +type WebhookPullResult = { status: 'success' | 'skipped' | 'error'; message: string }; + // ─── Constants ─────────────────────────────────────────────────────────────── const TEMP_DIR_PREFIX = 'sencho-git-'; @@ -568,6 +630,7 @@ export class GitSourceService { auth_type: src.auth_type, has_token: !!src.encrypted_token, has_deploy_key: !!src.encrypted_deploy_key, + has_ca_bundle: !!src.encrypted_ca_bundle, ssh_host_key_fingerprint: src.ssh_host_key_fingerprint ?? null, auto_apply_on_webhook: src.auto_apply_on_webhook, auto_deploy_on_apply: src.auto_deploy_on_apply, @@ -645,25 +708,58 @@ export class GitSourceService { this.recordSshTrustAudit({ ...auditContext, stackName, fingerprint, action }); } - private resolveTransportAuth(src: Pick): { + private resolveTransportAuth(src: Pick): { token?: string | null; sshAuth?: SshDeployKeyAuth | null; + caBundlePem?: string | null; } { + const caBundlePem = src.encrypted_ca_bundle ? this.crypto.decrypt(src.encrypted_ca_bundle) : null; if (src.auth_type === 'token') { - return { token: src.encrypted_token ? this.crypto.decrypt(src.encrypted_token) : null }; + return { + token: src.encrypted_token ? this.crypto.decrypt(src.encrypted_token) : null, + caBundlePem, + }; } if (src.auth_type === 'deploy_key') { if (!src.encrypted_deploy_key || !src.ssh_known_hosts_entry) { - return { sshAuth: null }; + return { sshAuth: null, caBundlePem }; } return { sshAuth: { privateKey: this.crypto.decrypt(src.encrypted_deploy_key), knownHostsEntry: src.ssh_known_hosts_entry, }, + caBundlePem, }; } - return { token: null }; + return { token: null, caBundlePem }; + } + + private resolveEncryptedCaBundle( + caBundle: string | null | undefined, + removeCaBundle: boolean | undefined, + existing?: StackGitSource, + ): string | null { + // Explicit revocation always wins, even when the field is omitted: + // the operator clicked "Remove stored CA" and the value field is + // left empty (matching the write-only input), so we must not silently + // preserve the stored bundle. + if (removeCaBundle === true) return null; + if (caBundle === undefined) return existing?.encrypted_ca_bundle ?? null; + if (caBundle === null || caBundle === '') return null; + const validated = validateCaBundlePem(caBundle); + if (!validated) { + throw new GitSourceError( + 'GIT_ERROR', + 'Custom CA bundle must contain one or more PEM certificates.', + ); + } + return this.crypto.encrypt(validated); + } + + private decryptCaBundlePem(encrypted: string | null | undefined): string | null { + if (!encrypted) return null; + return this.crypto.decrypt(encrypted); } public async upsert(input: UpsertInput): Promise { @@ -675,6 +771,8 @@ export class GitSourceService { let encryptedDeployKey: string | null = null; let sshKnownHostsEntry: string | null = null; let sshHostKeyFingerprint: string | null = null; + const encryptedCaBundle = this.resolveEncryptedCaBundle(input.caBundle, input.removeCaBundle, existing); + const caBundlePem = this.decryptCaBundlePem(encryptedCaBundle); if (input.authType === 'none') { // all null @@ -741,23 +839,33 @@ export class GitSourceService { // Dry-run reachability check before persisting. Fetches every configured // file so a bad path in the ordered list is caught at save time. - const fetchAuth = input.authType === 'token' - ? { token: encryptedToken ? this.crypto.decrypt(encryptedToken) : null } - : input.authType === 'deploy_key' - ? { - sshAuth: { - privateKey: this.crypto.decrypt(encryptedDeployKey!), - knownHostsEntry: sshKnownHostsEntry!, - }, - } - : { token: null }; - await this.fetchFromGit({ - repoUrl: input.repoUrl, - branch: input.branch, - composePaths: input.composePaths, - envPath: input.syncEnv ? input.envPath : null, - ...fetchAuth, - }); + // + // Skipped for an explicit CA removal: removing the one CA a server + // needs to be reached makes this exact fetch fail, which would refuse + // the removal itself with a TLS error and leave the operator unable to + // retire a CA they no longer trust. The intent behind + // `remove_ca_bundle: true` is unambiguous, so the save proceeds and the + // next pull reports the real reachability state. + if (!input.removeCaBundle) { + const fetchAuth = input.authType === 'token' + ? { token: encryptedToken ? this.crypto.decrypt(encryptedToken) : null } + : input.authType === 'deploy_key' + ? { + sshAuth: { + privateKey: this.crypto.decrypt(encryptedDeployKey!), + knownHostsEntry: sshKnownHostsEntry!, + }, + } + : { token: null }; + await this.fetchFromGit({ + repoUrl: input.repoUrl, + branch: input.branch, + composePaths: input.composePaths, + envPath: input.syncEnv ? input.envPath : null, + ...fetchAuth, + caBundlePem, + }); + } const resolvedEnvPath = input.syncEnv ? input.envPath : null; // A pending pull captured the files/contextDir for the previous config. If @@ -801,6 +909,7 @@ export class GitSourceService { encrypted_deploy_key: encryptedDeployKey, ssh_known_hosts_entry: sshKnownHostsEntry, ssh_host_key_fingerprint: sshHostKeyFingerprint, + encrypted_ca_bundle: encryptedCaBundle, auto_apply_on_webhook: input.autoApplyOnWebhook, auto_deploy_on_apply: input.autoDeployOnApply, last_applied_commit_sha: existing?.last_applied_commit_sha ?? null, @@ -812,13 +921,13 @@ export class GitSourceService { last_debounce_at: existing?.last_debounce_at ?? null, }); - if (configChanged) { + const app = this.gitopsApplicationFor(input.stackName); + if (configChanged || !app) { db.clearGitSourcePending(input.stackName); } - const app = this.gitopsApplicationFor(input.stackName); const envelope = this.gitopsEnvelope(crypto.randomUUID(), 'system:git-source', 'configure'); - if (!app && !existing && !this.gitopsNameHeld(input.stackName)) { + if (!app && !this.gitopsNameHeld(input.stackName)) { // Linking a stack that already exists. Nothing is fetched or // accepted yet, so the application starts live with no desired // commit and the projection asks for a fetch. @@ -1121,13 +1230,14 @@ export class GitSourceService { branch: string; token?: string | null; sshAuth?: SshDeployKeyAuth | null; + caBundlePem?: string | null; timeoutMs?: number; hasPriorHistory?: boolean; priorIdentity?: { commitSha: string; kind: RefKind }; }, fn: (dir: string, commitSha: string, warnings: string[], resolvedRefKind: RefKind) => Promise, ): Promise { - const { repoUrl, branch, token, sshAuth } = params; + const { repoUrl, branch, token, sshAuth, caBundlePem } = params; const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; const root = await createTempDir(); const hasPriorHistory = params.hasPriorHistory === true || params.priorIdentity != null; @@ -1138,6 +1248,7 @@ export class GitSourceService { ref: branch, token, sshAuth, + caBundlePem, timeoutMs, workspaceRoot: root, }); @@ -1153,6 +1264,7 @@ export class GitSourceService { descendantSha: resolved.commitSha, token, sshAuth, + caBundlePem, timeoutMs, workspaceRoot: root, maxBytes: maxCloneBytes(), @@ -1168,6 +1280,7 @@ export class GitSourceService { refKind: resolved.kind, token, sshAuth, + caBundlePem, timeoutMs, commitSha: resolved.commitSha, workspaceRoot: root, @@ -1201,9 +1314,9 @@ export class GitSourceService { // A ref that resolved before but no longer does is a deletion // or force-push, distinct from a mis-typed ref on first link. if (classified.code === 'REF_NOT_FOUND' && hasPriorHistory) { - throw new GitSourceError('REF_DELETED', REF_DELETED_MESSAGE); + throw new GitSourceError('REF_DELETED', REF_DELETED_MESSAGE, { transportReason: e.reason }); } - throw new GitSourceError(classified.code, classified.message); + throw new GitSourceError(classified.code, classified.message, { transportReason: e.reason }); } throw e; } finally { @@ -1235,6 +1348,7 @@ export class GitSourceService { branch, token, sshAuth, + caBundlePem: params.caBundlePem, timeoutMs: params.timeoutMs, hasPriorHistory: params.hasPriorHistory, priorIdentity: params.priorIdentity, @@ -1304,7 +1418,14 @@ export class GitSourceService { * same clone size/timeout guards as fetch, plus a file-count cap. */ public async listRepoTree( - params: { repoUrl: string; branch: string; token?: string | null; sshAuth?: SshDeployKeyAuth | null; timeoutMs?: number }, + params: { + repoUrl: string; + branch: string; + token?: string | null; + sshAuth?: SshDeployKeyAuth | null; + caBundlePem?: string | null; + timeoutMs?: number; + }, ): Promise<{ files: string[]; truncated: boolean; commitSha: string; warnings: string[] }> { return this.withClonedRepo(params, async (dir, commitSha, warnings) => { const { files, truncated } = await this.walkRepoFiles(dir); @@ -1807,29 +1928,68 @@ export class GitSourceService { // ─── Pull / apply ──────────────────────────────────────────────────────── + /** + * A short, log-friendly token for an operation id. A reserved id + * (`:attempt:` or `::`) + * repeats the same prefix across every attempt on one stack, so only the + * suffix after the last colon tells two attempts apart. A plain UUID has + * no such suffix, so its leading characters are used instead. + */ + private static shortOperationId(operationId: string): string { + const lastColon = operationId.lastIndexOf(':'); + return lastColon === -1 ? operationId.slice(0, 8) : operationId.slice(lastColon + 1); + } + public async pull(stackName: string, opts: { actor?: string } = {}): Promise { // Guarded by the per-stack mutex (see withStackLock). Without this, a // concurrent delete-source + pull can land a pending row on a stack // whose config row has just been removed. const actor = opts.actor ?? 'unknown'; - return this.withStackLock(stackName, async () => { + + // Reservation and coalescing need a real gitops application to attach a + // durable attempt to, the same definition pullLocked itself uses to + // decide whether it has any gitops bookkeeping to do at all. + const gitopsApp = this.gitopsApplicationFor(stackName); + const doPull = this.doPullWork(stackName, actor, gitopsApp?.id); + if (!gitopsApp) { + this.refuseUntrackedSource(stackName, actor, 'fetch', true); + return doPull(); + } + + const request: ReconcileRequest = { intent: 'fetch', applicationId: gitopsApp.id, stackName, trigger: 'manual', actor }; + const submission = await this.submitExecution( + this.inFlightFetches, + request, + doPull, + (outcome) => this.fetchExecutionResult(stackName, outcome), + ); + return GitSourceService.valueFromSubmission(submission); + } + + /** + * The shared fetch work closure: the per-stack lock plus pullLocked + * itself, reporting a real failure to the server console and to the + * stack's activity feed here rather than in each producer, so it is + * recorded exactly once no matter which fetch-intent producer (pull(), + * reconcile()) owns the reservation that ends up leading. Kept as one + * factory so pull() and reconcile()'s fetch path run the literal same + * closure shape, which is what lets them join the same coalescing map + * (inFlightFetches) in the first place: two producers can only coalesce + * onto one execution if that execution really is the same work. + */ + private doPullWork(stackName: string, actor: string, applicationId?: string): (operationId?: string) => Promise { + return (operationId?: string) => this.withStackLock(stackName, async () => { try { - return await this.pullLocked(stackName, actor); + if (applicationId) this.assertLiveApplication(stackName, applicationId); + return await this.pullLocked(stackName, actor, operationId); } catch (e) { + console.error(`[GitSource] fetch failed for ${sanitizeForLog(stackName)}:`, e instanceof Error ? e.message : String(e)); this.recordGitActivity(stackName, 'git_pull_failed', `Git pull failed for ${stackName}`, actor, 'error'); throw e; } }); } - /** - * Body of pull(); assumes the caller already holds the per-stack lock. - * handleWebhookPull calls this directly so that its debounce re-check, - * this fetch, and the apply all run inside the single lock that - * handleWebhookPull holds. Without that, a concurrent webhook fan-out - * reads last_debounce_at while it is still unset on every request, slips - * past the gate, and clones once per request. - */ /** * The GitOps application tracking this stack, or null when there is none. * @@ -1899,12 +2059,23 @@ export class GitSourceService { }); } - private async pullLocked(stackName: string, actor: string): Promise { + private async pullLocked(stackName: string, actor: string, operationId?: string): Promise { const db = DatabaseService.getInstance(); const src = db.getGitSource(stackName); if (!src) throw new GitSourceError('GIT_ERROR', 'No Git source configured for this stack.'); const gitopsApp = this.gitopsApplicationFor(stackName); - const gitopsOperationId = crypto.randomUUID(); + // fetchStarted has its own suspension guard, but recordGitOps swallows + // its rejection (so a fetch that already touched the filesystem is never + // failed out from under itself), which would let a suspended source keep + // cloning and staging pending updates. Stop before any of that starts. + if (gitopsApp?.suspended_at) { + throw new GitSourceError('OPERATION_IN_FLIGHT', `Reconciliation is suspended for ${stackName}.`); + } + // A caller that reserved a durable attempt for this fetch passes its own + // operation id in, so the attempt and every stage of gitops evidence it + // produces share one identity. A direct low-level call that reserved + // nothing still gets an id of its own. + const gitopsOperationId = operationId ?? crypto.randomUUID(); const gitopsEnv = this.gitopsEnvelope(gitopsOperationId, actor, 'pull'); // A fetch that starts and never terminates is worse than one that is // never recorded: fetchStarted refuses to open a second operation, so @@ -1974,6 +2145,7 @@ export class GitSourceService { envPath: src.sync_env ? src.env_path : null, token: transportAuth.token, sshAuth: transportAuth.sshAuth, + caBundlePem: transportAuth.caBundlePem, hasPriorHistory: priorIdentity != null, priorIdentity, onClone: async (cloneDir, commitSha, envContent) => { @@ -1999,7 +2171,6 @@ export class GitSourceService { const prior = priorRead; if (prior) manifestSummary = manifestSvc.summaryFrom(prior); - const operationId = crypto.randomUUID(); let plan: GitChangePlan | null = null; if (materialization.value?.inventory) { plan = await this.computeChangePlan({ @@ -2101,7 +2272,13 @@ export class GitSourceService { { fingerprint: plan?.fingerprint ?? '', schemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION, - operationId, + // This fetch attempt's own id, not an independent one: + // applyLockedBody falls back to pending.operationId for + // its gitops transitions when its caller reserved no + // attempt, so that fallback has to inherit the real fetch + // attempt's lineage rather than an identity nothing else + // knows about. + operationId: gitopsOperationId, reviewedLive: plan ? this.reviewedLiveFromPlan(plan) : [], }, ), @@ -2117,7 +2294,7 @@ export class GitSourceService { this.recordGitActivity( stackName, 'git_plan_blocked', - `Git plan blocked for ${stackName} (${shortSha}, op ${operationId.slice(0, 8)}, plan ${fpPrefix})`, + `Git plan blocked for ${stackName} (${shortSha}, op ${GitSourceService.shortOperationId(gitopsOperationId)}, plan ${fpPrefix})`, actor, 'warning', ); @@ -2125,7 +2302,7 @@ export class GitSourceService { this.recordGitActivity( stackName, 'git_pull_ready', - `Git pull ready for ${stackName} (${shortSha}, op ${operationId.slice(0, 8)}, plan ${fpPrefix})`, + `Git pull ready for ${stackName} (${shortSha}, op ${GitSourceService.shortOperationId(gitopsOperationId)}, plan ${fpPrefix})`, actor, ); } @@ -2164,11 +2341,892 @@ export class GitSourceService { stackName: string, commitSha: string, opts: GitApplyOpts = {}, - ): Promise<{ applied: boolean; deployed: boolean; deployError?: string; recoveryId?: string }> { - return this.withStackLock(stackName, () => this.applyWithSharedLock(stackName, commitSha, { - ...opts, - requirePlanFingerprint: opts.requirePlanFingerprint !== false, - })); + ): Promise { + // Resolved once, with applyLockedBody's own formula (its shouldDeploy), + // so the coalesce key below and the deploy behavior actually executed + // can never disagree: two calls that resolve to different deploy + // behavior must never share a coalesce key, or one could silently + // receive the other's deployed/not-deployed result. + const resolvedDeploy = opts.deploy ?? DatabaseService.getInstance().getGitSource(stackName)?.auto_deploy_on_apply ?? false; + const finalOpts: GitApplyOpts = { ...opts, deploy: resolvedDeploy, requirePlanFingerprint: opts.requirePlanFingerprint !== false }; + const doApply = (applicationId?: string, operationId?: string) => this.withStackLock( + stackName, + async () => { + if (applicationId) this.assertLiveApplication(stackName, applicationId); + return this.applyWithSharedLock(stackName, commitSha, finalOpts, operationId); + }, + ); + + // Same reasoning as pull(): reservation needs a real gitops + // application to attach a durable attempt to. + const gitopsApp = this.gitopsApplicationFor(stackName); + if (!gitopsApp) { + this.refuseUntrackedSource(stackName, opts.actor ?? 'unknown', 'apply', true); + return doApply(); + } + + const request: ReconcileRequest = { + intent: 'apply', + applicationId: gitopsApp.id, + stackName, + trigger: 'manual', + actor: opts.actor ?? 'unknown', + commitSha, + planFingerprint: opts.planFingerprint ?? '', + deploy: resolvedDeploy, + }; + // A policy-bypassing apply must never coalesce with anything else: + // bypassPolicy changes behavior but is not part of the coalesce key (a + // plain policy-gate bypass has no natural identity to key on), so + // joining could hand a non-bypassing caller someone else's bypassed + // result, or silently drop an admin's explicit bypass onto a request + // that never asked for one. A call-local key suffix guarantees this + // call can neither join an existing leader nor be joined by a later one. + const baseKey = GitSourceService.applyExecutionKey(request, finalOpts.requirePlanFingerprint === true); + const key = opts.bypassPolicy + ? `${baseKey}:policy-bypass:${crypto.randomUUID()}` + : baseKey; + const submission = await this.submitExecution( + this.inFlightApplies, + request, + (operationId) => doApply(gitopsApp.id, operationId), + (outcome) => this.applyExecutionResult(stackName, outcome), + key, + ); + return GitSourceService.valueFromSubmission(submission); + } + + /** + * Outcomes the application row already reflects truthfully, so reconcile + * trusts the derived result over a classified fallback. Most throw sites + * in pullLocked/applyLockedBody fire before any transition opens (missing + * config, stale commitSha, lock contention) and leave the row saying + * nothing about the failure, which is what the fallback covers; + * 'suspended' belongs here because its guards throw only when the row + * already, correctly, says so. + */ + private static readonly FAILURE_REFLECTED_OUTCOMES: ReadonlySet = new Set([ + 'failed_previous_intact', + 'retry_scheduled', + 'recovery_required', + 'blocked', + 'suspended', + ]); + + /** The settled result for a stack that carries no GitOps application at all. */ + private static noApplicationResult(): ReconcileResult { + return { outcome: 'unknown', reason: 'No GitOps application exists for this stack.', nextAction: 'none' }; + } + + /** + * The settled result for a request naming an application id that is no + * longer the live one for its stack. Failing closed here (rather than + * proceeding against whatever application now holds the stack name) + * keeps a request from settling against an application it never named. + */ + private static staleApplicationResult(): ReconcileResult { + return { + outcome: 'unknown', + reason: 'The live application for this stack no longer matches the requested application id.', + nextAction: 'none', + }; + } + + /** Refuse work when the application resolved before locking is no longer live. */ + private assertLiveApplication(stackName: string, applicationId: string): void { + if (GitOpsStore.getInstance().getLiveDirectApplication(stackName)?.id === applicationId) return; + throw new GitSourceError('OPERATION_IN_FLIGHT', GitSourceService.staleApplicationResult().reason); + } + + /** + * Shared fetch and apply executions. Manual routes, controller requests, + * and webhooks all use these maps so producer choice cannot create a + * duplicate side effect or a different normalized result. + */ + private readonly inFlightFetches: InFlightMap = new Map(); + private readonly inFlightApplies: InFlightMap = new Map(); + private readonly inFlightWebhookDeliveries = new Map }>(); + + /** + * The controller-facing entry point: one normalized submission in, + * one normalized result out, for any trigger (manual, poll, retry, + * API, config change, startup, resume). + * + * Every submission against a real application row gets a durable + * attempt, reserved before any side effect and settled with the + * normalized result once execution finishes -- reserved but never + * settled is exactly what startup recovery looks for after a crash. + * A submission naming an application id that does not exist at all + * (a fabricated id, or a stack with no GitOps application) reserves + * nothing: there is no row to attach a durable attempt to, and the + * identity/no-application guards below already produce a truthful + * result for it without doing any work worth protecting. + */ + public async reconcile(request: ReconcileRequest): Promise { + if (!GitOpsStore.getInstance().getApplication(request.applicationId)) { + // request.applicationId does not exist as any row, so it can + // never equal a real live application's id: the identity guard + // alone already produces the truthful result, without a stack + // lock or any work worth protecting. + const liveApp = GitOpsStore.getInstance().getLiveDirectApplication(request.stackName); + return liveApp ? GitSourceService.staleApplicationResult() : GitSourceService.noApplicationResult(); + } + + if (request.intent === 'fetch') { + return this.reconcileFetch(request); + } + + const submission = await this.submitExecution( + this.inFlightApplies, + request, + (operationId) => this.withStackLock(request.stackName, async () => { + this.assertLiveApplication(request.stackName, request.applicationId); + return this.applyWithSharedLock(request.stackName, request.commitSha, { + actor: request.actor, + deploy: request.deploy, + planFingerprint: request.planFingerprint, + requirePlanFingerprint: false, + }, operationId); + }), + (outcome) => this.applyExecutionResult(request.stackName, outcome), + GitSourceService.applyExecutionKey(request, false), + ); + return GitSourceService.resultFromSubmission(submission); + } + + private static applyExecutionKey( + request: ReconcileRequest & { intent: 'apply' }, + requirePlanFingerprint: boolean, + ): string { + return `${coalesceKey(request)}:fingerprint-${requirePlanFingerprint ? 'required' : 'optional'}`; + } + + /** + * The in-flight execution in `map` whose reservation minted exactly this + * operation id, regardless of which coalesce key it is running under. + * Coalesce keys and operation ids are not co-extensive (an apply's key + * includes its commitSha/planFingerprint/deploy, which a shared external + * delivery id does not carry), so two submissions can collide on operation + * id while running under different keys. + */ + private static findByOperationId(map: InFlightMap, operationId: string): Promise> | undefined { + for (const entry of map.values()) { + if (entry.operationId === operationId) return entry.promise; + } + return undefined; + } + + private static resultFromSubmission(submission: ExecutionSubmission): ReconcileResult { + return submission.kind === 'replayed' ? submission.result : submission.execution.result; + } + + /** Preserve manual route return and throw behavior after settlement is attempted. */ + private static valueFromSubmission(submission: ExecutionSubmission): T { + if (submission.kind === 'replayed') { + throw new GitSourceError('GIT_ERROR', 'This operation was already recorded and cannot be replayed as a new manual request.'); + } + if (submission.execution.status === 'rejected') throw submission.execution.reason; + return submission.execution.value; + } + + /** + * Reserve one submission, join equivalent work when possible, and attempt + * to settle each reservation from the leader's single normalized result. + * A failed leader settlement leaves its followers unsettled for recovery. + */ + private async submitExecution( + map: InFlightMap, + request: ReconcileRequest, + work: (operationId: string) => Promise, + normalize: (outcome: WorkOutcome) => ReconcileResult, + key = coalesceKey(request), + deliveryIntent?: ReconcileDeliveryIntent, + ): Promise> { + const leader = map.get(key); + const { envelope, reserved } = this.reserveOwnAttemptOrFailClosed(request, leader?.operationId, deliveryIntent); + + if (leader && (reserved || leader.operationId === envelope.operationId)) { + const completion = await leader.promise; + if (reserved && completion.settled) { + this.settleAttempt(request.applicationId, envelope, completion.execution.result); + } + return { kind: 'executed', execution: completion.execution }; + } + + if (!reserved) { + const byOperationId = GitSourceService.findByOperationId(map, envelope.operationId); + if (byOperationId) { + return { kind: 'executed', execution: (await byOperationId).execution }; + } + return { + kind: 'replayed', + result: this.resolveAlreadyReservedAttempt( + request.applicationId, + envelope.operationId, + request.actor, + request.trigger, + ), + }; + } + + const promise = this.captureExecution(request, envelope, work, normalize) + .then((execution): LeaderCompletion => ({ + execution, + settled: this.settleAttempt(request.applicationId, envelope, execution.result), + })); + const entry = { operationId: envelope.operationId, promise }; + map.set(key, entry); + try { + const completion = await promise; + return { kind: 'executed', execution: completion.execution }; + } finally { + if (map.get(key) === entry) map.delete(key); + } + } + + /** Capture raw producer behavior and compute one normalized result for all followers. */ + private async captureExecution( + request: ReconcileRequest, + envelope: EventEnvelope, + work: (operationId: string) => Promise, + normalize: (outcome: WorkOutcome) => ReconcileResult, + ): Promise> { + let outcome: WorkOutcome; + try { + outcome = { status: 'fulfilled', value: await work(envelope.operationId) }; + } catch (reason) { + outcome = { status: 'rejected', reason: reason ?? new Error('Rejected with no reason.') }; + } + try { + return { ...outcome, result: normalize(outcome) }; + } catch (e) { + console.error( + '[GitSource] Failed to derive a settlement result for attempt %s on application %s:', + sanitizeForLog(envelope.operationId), + sanitizeForLog(request.applicationId), + e instanceof Error ? e.message : String(e), + ); + return { + ...outcome, + result: { outcome: 'unknown', reason: 'This attempt could not be resolved.', nextAction: 'none' }, + }; + } + } + + /** + * Reserve this request's durable attempt, failing closed when the + * reservation bookkeeping itself fails: no fetch, apply, promotion, or + * deploy may run without a durable record of it, so a failure here stops + * the operation rather than letting it proceed as an untracked side + * effect. Two failure shapes, reported differently: an application torn + * down in the window between resolving it and reserving against it (a + * GitOpsTransitionError from requireApp) can never succeed on retry, so + * it gets its own message; anything else (a transient DB error) is worth + * retrying. The refusal is recorded to the stack's own activity history + * as well as the server console, since it is itself an event an operator + * needs to see later. Callers (an HTTP route, or handleWebhookPull's own + * try/catch) already handle a thrown GitSourceError the same way they + * handle any other failure from the work itself. + */ + private reserveOwnAttemptOrFailClosed( + request: ReconcileRequest, + followerOf: string | undefined, + deliveryIntent?: ReconcileDeliveryIntent, + ): { envelope: EventEnvelope; reserved: boolean } { + try { + return this.reserveOwnAttempt(request, followerOf, deliveryIntent); + } catch (e) { + console.error( + `[GitSource] Failed to reserve a durable attempt for application ${sanitizeForLog(request.applicationId)}; refusing to proceed without one:`, + e instanceof Error ? e.stack ?? e.message : String(e), + ); + const isFetch = request.intent === 'fetch'; + this.recordGitActivity( + request.stackName, + isFetch ? 'git_pull_failed' : 'git_apply_failed', + `Git ${request.intent} for ${request.stackName} was refused: could not durably record the attempt.`, + request.actor, + 'error', + ); + if (e instanceof GitOpsTransitionError) { + throw new GitSourceError( + 'GIT_ERROR', + `This stack's GitOps tracking is unavailable; reconfigure the source before ${isFetch ? 'pulling' : 'applying'} again.`, + ); + } + throw new GitSourceError('GIT_ERROR', 'Could not durably record this operation. Please try again.'); + } + } + + /** Refuse any configured source whose work cannot receive a durable attempt. */ + private refuseUntrackedSource(stackName: string, actor: string, intent: 'fetch' | 'apply', recordActivity: boolean): void { + if (!DatabaseService.getInstance().getGitSource(stackName)) return; + const wasDetached = GitOpsStore.getInstance().hasDetachedDirectApplication(stackName); + const message = wasDetached + ? `This stack's GitOps tracking was removed but its Git source configuration still exists; delete the Git source configuration to finish detaching before ${intent === 'fetch' ? 'pulling' : 'applying'} again.` + : `This stack's GitOps tracking is unavailable; reconfigure the source before ${intent === 'fetch' ? 'pulling' : 'applying'} again.`; + if (recordActivity) { + this.recordGitActivity(stackName, intent === 'fetch' ? 'git_pull_failed' : 'git_apply_failed', message, actor, 'error'); + } + throw new GitSourceError('GIT_ERROR', message); + } + + /** + * Reserve this submission's own durable attempt. A request carrying a + * stable external delivery id (webhook redelivery) reserves under a + * producer-namespaced key derived from it, so a redelivery of the same + * event reuses the same operation id and reports `reserved: false` + * rather than minting a second attempt. Any other submission has no + * such stable identity, so its operation id is freshly allocated from + * the row's own attemptSeq, which is always a first-time reservation. + */ + private reserveOwnAttempt( + request: ReconcileRequest, + followerOf: string | undefined, + deliveryIntent?: ReconcileDeliveryIntent, + ): { envelope: EventEnvelope; reserved: boolean } { + const tx = GitOpsTransitions.getInstance(); + if (request.deliveryId) { + const operationId = deliveryKey(request.trigger, request.intent, request.deliveryId); + const envelope = this.gitopsEnvelope(operationId, request.actor, request.trigger); + const { reserved } = tx.reserveReconcileAttempt(request.applicationId, envelope, followerOf, deliveryIntent); + return { envelope, reserved }; + } + const allocated = tx.allocateReconcileAttempt(request.applicationId, request.actor, request.trigger, Date.now(), followerOf); + return { + envelope: this.gitopsEnvelope(allocated.operationId, request.actor, request.trigger), + reserved: allocated.reserved, + }; + } + + /** + * Settle a durable attempt with its already-computed result, tolerating + * a settlement failure rather than letting it turn a correctly-computed + * result (up to and including a real fetch or apply that already + * touched the filesystem) into a thrown error for the caller. The + * attempt is left unsettled on this path, which is exactly the signal + * startup recovery looks for, so nothing here is lost, only deferred. + */ + private settleAttempt(applicationId: string, envelope: EventEnvelope, result: ReconcileResult): boolean { + try { + const { settled } = GitOpsTransitions.getInstance().settleReconcileAttempt(applicationId, envelope, result); + return settled || !!GitOpsStore.getInstance().getSettledAttempt(applicationId, envelope.operationId); + } catch (e) { + console.error( + '[GitSource] Failed to settle reconcile attempt %s for application %s:', + sanitizeForLog(envelope.operationId), + sanitizeForLog(applicationId), + e instanceof Error ? e.message : String(e), + ); + return false; + } + } + + /** + * A submission whose operation id was already reserved elsewhere, with + * no leader for it running in this process: a settled row means a + * duplicate delivery arrived after its original attempt finished, so + * its stored result is returned rather than repeating the work. No + * settled row means the original attempt was orphaned by a crash (in + * this process or another); either way this call must not re-execute a + * fetch or apply someone else may already have run, so it resolves + * from whatever is already durably recorded, settling when that + * yields a real answer and otherwise reporting truthfully that the + * outcome is not yet known rather than guessing one. + */ + private resolveAlreadyReservedAttempt(applicationId: string, operationId: string, actor: string | null, trigger: string): ReconcileResult { + try { + const store = GitOpsStore.getInstance(); + const settled = store.getSettledAttempt(applicationId, operationId); + if (settled) return GitSourceService.resultFromSettledAttempt(settled); + return this.settleFromDurableState(applicationId, operationId, actor, trigger); + } catch (e) { + console.error( + '[GitSource] Failed to resolve already-reserved attempt %s for application %s:', + sanitizeForLog(operationId), + sanitizeForLog(applicationId), + e instanceof Error ? e.message : String(e), + ); + return { outcome: 'unknown', reason: 'This attempt could not be resolved from durable state.', nextAction: 'none' }; + } + } + + /** + * Resolve one reconcile attempt purely from what is already recorded, + * never by re-executing a fetch or apply, settling it durably when that + * yields a real answer. A follower is settled from its leader; anything + * else is derived from the application's current row state. A follower + * whose leader is still unresolved is left unsettled for a later call + * (a future recovery pass, or the leader itself finally settling) to + * resolve, for the reason resolveFollowerOutcome states. + */ + private settleFromDurableState( + applicationId: string, + operationId: string, + actor: string | null, + trigger: string, + ): ReconcileResult { + const started = GitOpsStore.getInstance().getStartedAttempt(applicationId, operationId); + // Prefer the reservation's own recorded actor/trigger over this + // call's, so the settled row's audit trail reflects who and what + // actually reserved the attempt rather than whoever happened to + // resolve it later. + const envelope: EventEnvelope = { + operationId, + actor: started?.actor ?? actor, + trigger: started?.trigger ?? trigger, + at: Date.now(), + }; + const followerOf = started ? GitSourceService.followerOfFromRow(started) : undefined; + if (followerOf) { + const outcome = this.resolveFollowerOutcome(applicationId, followerOf); + if (!outcome.known) { + return { outcome: 'unknown', reason: 'This attempt is waiting on its leader to settle.', nextAction: 'none' }; + } + if (!this.settleAttempt(applicationId, envelope, outcome.result)) { + return GitSourceService.durableResolutionFailureResult(); + } + return outcome.result; + } + const result = this.deriveResultForApplication(applicationId); + if (!this.settleAttempt(applicationId, envelope, result)) { + return GitSourceService.durableResolutionFailureResult(); + } + return result; + } + + private static durableResolutionFailureResult(): ReconcileResult { + return { outcome: 'unknown', reason: 'This attempt could not be durably resolved.', nextAction: 'none' }; + } + + /** + * A follower's outcome from its leader alone: the leader's settled + * result when it has one. When the leader has no settled row but its + * own reservation genuinely exists, its fate is still unresolved + * (`known: false`) and must not be guessed at independently, since it + * could settle to something else later and the follower would then + * durably disagree with it. Only when the leader's own reservation + * cannot be found at all (nothing durable to ever wait for) does + * independent derivation apply, logged distinctly since it means the + * leader/follower agreement invariant could not be honored here. + */ + private resolveFollowerOutcome( + applicationId: string, + leaderOperationId: string, + ): { known: true; result: ReconcileResult } | { known: false } { + const store = GitOpsStore.getInstance(); + const leaderSettled = store.getSettledAttempt(applicationId, leaderOperationId); + if (leaderSettled) return { known: true, result: GitSourceService.resultFromSettledAttempt(leaderSettled) }; + if (store.getStartedAttempt(applicationId, leaderOperationId)) return { known: false }; + console.error( + `[GitSource] follower's leader ${sanitizeForLog(leaderOperationId)} has no recorded reservation for application ${sanitizeForLog(applicationId)}; deriving independently`, + ); + return { known: true, result: this.deriveResultForApplication(applicationId) }; + } + + /** + * The current truthful result for an application, independent of any + * specific attempt. Fails closed on a superseded application id for the + * same reason every live execution revalidates identity under its lock. + */ + private deriveResultForApplication(applicationId: string): ReconcileResult { + const store = GitOpsStore.getInstance(); + const app = store.getApplication(applicationId); + if (!app?.stack_name) return GitSourceService.noApplicationResult(); + if (store.getLiveDirectApplication(app.stack_name)?.id !== applicationId) { + return GitSourceService.staleApplicationResult(); + } + return this.deriveReconcileResult(app.stack_name); + } + + /** The follower-link operation id recorded on a reservation, if any. */ + private static followerOfFromRow(row: GitOpsHistoryRow): string | undefined { + const decoded = decodeGitOpsJson(row.after_json); + if (!isRecord(decoded)) throw new GitOpsJsonError('reserved attempt metadata must be an object'); + if (!('followerOf' in decoded)) return undefined; + if (typeof decoded.followerOf !== 'string') { + throw new GitOpsJsonError('reserved attempt followerOf must be a string'); + } + return decoded.followerOf; + } + + private static deliveryIntentFromStartedAttempt(row: GitOpsHistoryRow): ReconcileDeliveryIntent { + const decoded = decodeGitOpsJson(row.after_json); + if (!isRecord(decoded) || !isRecord(decoded.deliveryIntent)) { + throw new GitOpsJsonError('reserved webhook attempt has no delivery intent'); + } + const { autoApply, deploy } = decoded.deliveryIntent; + if (typeof autoApply !== 'boolean' || typeof deploy !== 'boolean') { + throw new GitOpsJsonError('reserved webhook attempt has an invalid delivery intent'); + } + return GitSourceService.deliveryIntent(autoApply, deploy); + } + + private static deliveryIntent(autoApply: boolean, deploy: boolean): ReconcileDeliveryIntent { + return autoApply ? { autoApply: true, deploy } : { autoApply: false, deploy: false }; + } + + /** + * Decode a settled attempt's recorded result back into a + * ReconcileResult. Unreadable JSON and a well-formed-but-wrong-shaped + * payload are both logged: a corrupt or unexpected settled row is a + * storage or encoding bug an operator needs to see, not a routine + * response variation, matching decodeHistoryDelta's own rule for this + * exact column. + */ + private static resultFromSettledAttempt(row: GitOpsHistoryRow): ReconcileResult { + const unreadable: ReconcileResult = { + outcome: 'unknown', + reason: 'The settled attempt result could not be read.', + nextAction: 'none', + }; + + let decoded: unknown; + try { + decoded = decodeGitOpsJson(row.after_json); + } catch (e) { + if (!(e instanceof GitOpsJsonError)) throw e; + console.error(`[GitSource] settled attempt ${sanitizeForLog(row.operation_id)} is not decodable JSON: ${e.message}`); + return unreadable; + } + if ( + !isRecord(decoded) + || !isReconcileOutcome(decoded.outcome) + || typeof decoded.reason !== 'string' + || !isNextAction(decoded.nextAction) + ) { + console.error(`[GitSource] settled attempt ${sanitizeForLog(row.operation_id)} decoded to an unexpected shape`); + return unreadable; + } + return { + outcome: decoded.outcome, + reason: decoded.reason, + nextAction: decoded.nextAction, + retryAt: typeof decoded.retryAt === 'number' ? decoded.retryAt : undefined, + commitSha: typeof decoded.commitSha === 'string' ? decoded.commitSha : undefined, + }; + } + + /** + * Startup recovery: settle every reconcile attempt that reserved but + * never settled, most likely because the process crashed between the + * two. Never re-executes a fetch or apply. Must run before + * SourceController starts, so no live poll or retry tick can race a + * recovery pass over the same attempts. + * + * Pages by cursor rather than by "still unsettled" status, so a row + * this run cannot recover never blocks the rest of the backlog; see + * listUnsettledReconcileAttempts for why that matters. + * + * Two passes. Pass 1 settles every independent (non-follower) attempt + * by deriving the application's current truthful state, and defers + * every follower rather than settling it yet, so its leader (which + * can only be earlier in this same backlog, since a follower's own + * reservation records that its leader was already in flight) gets a + * chance to settle first. Pass 2 then settles each deferred follower + * from its leader's now-settled result, so a leader and its followers + * always agree; a follower whose leader is still unresolved is left + * for a later recovery run, per resolveFollowerOutcome. + * + * One row failing to recover (a transient DB error, an application + * deleted between listing and processing) is isolated: logged and + * counted, never allowed to block any other row. + */ + public async recoverUnsettledReconcileAttempts(pageSize = 200): Promise { + const store = GitOpsStore.getInstance(); + const tx = GitOpsTransitions.getInstance(); + let recovered = 0; + let failed = 0; + let stillWaiting = 0; + const deferredFollowers: { row: GitOpsHistoryRow; followerOf: string }[] = []; + + const settle = (row: GitOpsHistoryRow, result: ReconcileResult): void => { + tx.settleReconcileAttempt( + row.application_id, + { operationId: row.operation_id, actor: row.actor, trigger: row.trigger, at: Date.now() }, + result, + ); + recovered++; + }; + const noteFailure = (row: GitOpsHistoryRow, e: unknown): void => { + failed++; + console.error( + `[GitSource] Failed to recover reconcile attempt ${sanitizeForLog(row.operation_id)} for application ${sanitizeForLog(row.application_id)}:`, + e instanceof Error ? e.message : String(e), + ); + }; + + // Last-resort guard: the cursor advances strictly past every page, so + // the loop is already bounded by the size of the backlog itself. + const MAX_PAGES = 10_000; + let cursor: GitOpsHistoryCursor | undefined; + let pagesRead = 0; + for (; pagesRead < MAX_PAGES; pagesRead++) { + const page = store.listUnsettledReconcileAttempts(pageSize, cursor); + if (page.length === 0) break; + const last = page[page.length - 1]; + cursor = { createdAt: last.created_at, id: last.id }; + for (const row of page) { + try { + const followerOf = GitSourceService.followerOfFromRow(row); + if (followerOf) { + deferredFollowers.push({ row, followerOf }); + continue; + } + settle(row, this.deriveResultForApplication(row.application_id)); + } catch (e) { + noteFailure(row, e); + } + } + if (page.length < pageSize) break; + } + if (pagesRead === MAX_PAGES) { + console.warn(`[GitSource] Reconcile-attempt recovery stopped at its per-run page cap (${MAX_PAGES} pages); remaining rows will be retried on the next startup.`); + } + + for (const { row, followerOf } of deferredFollowers) { + try { + const outcome = this.resolveFollowerOutcome(row.application_id, followerOf); + if (!outcome.known) { + stillWaiting++; + continue; + } + settle(row, outcome.result); + } catch (e) { + noteFailure(row, e); + } + } + + if (recovered > 0 || failed > 0 || stillWaiting > 0) { + console.log(`[GitSource] Reconcile-attempt recovery: ${recovered} settled, ${failed} could not be recovered, ${stillWaiting} still waiting on their leader.`); + } + } + + /** Run fetch-intent reconcile through the same durable execution as pull(). */ + private async reconcileFetch(request: ReconcileRequest & { intent: 'fetch' }): Promise { + const liveApp = GitOpsStore.getInstance().getLiveDirectApplication(request.stackName); + if (!liveApp) return GitSourceService.noApplicationResult(); + if (liveApp.id !== request.applicationId) return GitSourceService.staleApplicationResult(); + + const submission = await this.submitExecution( + this.inFlightFetches, + request, + this.doPullWork(request.stackName, request.actor, request.applicationId), + (outcome) => this.fetchExecutionResult(request.stackName, outcome), + ); + return GitSourceService.resultFromSubmission(submission); + } + + /** Normalize one shared fetch execution for its leader and all followers. */ + private fetchExecutionResult(stackName: string, outcome: WorkOutcome): ReconcileResult { + return this.finalizeReconcileOutcome(stackName, outcome.status === 'rejected' ? outcome.reason : undefined); + } + + /** Normalize one shared apply execution for its leader and all followers. */ + private applyExecutionResult(stackName: string, outcome: WorkOutcome): ReconcileResult { + if (outcome.status === 'fulfilled' && outcome.value.deployError) { + return { + outcome: 'recovery_required', + reason: `The source applied, but the deploy failed: ${outcome.value.deployError}`, + nextAction: 'view_target_results', + }; + } + return this.finalizeReconcileOutcome(stackName, outcome.status === 'rejected' ? outcome.reason : undefined); + } + + /** + * The result derived from the application's own row state, unless a + * failure occurred that the derived state does not already reflect, in + * which case the failure itself is classified instead. + */ + private finalizeReconcileOutcome(stackName: string, failure: unknown): ReconcileResult { + const derived = this.deriveReconcileResult(stackName); + if (failure === undefined || GitSourceService.FAILURE_REFLECTED_OUTCOMES.has(derived.outcome)) { + return derived; + } + return this.reconcileFailureResult(failure); + } + + /** + * A truthful fallback for a reconcile failure the application row does + * not yet reflect. Routes through the same classifyFailure disposition + * table the controller's own retry/backoff logic uses, so an unretryable + * failure is never reported with nextAction: 'retry'. + */ + private reconcileFailureResult(failure: unknown): ReconcileResult { + if (!(failure instanceof GitSourceError)) { + return { + outcome: 'failed_previous_intact', + reason: 'The reconcile attempt failed unexpectedly.', + nextAction: 'retry', + }; + } + const disposition = classifyFailure({ + kind: 'git_source_error', + code: failure.code, + transportReason: failure.extras?.transportReason, + }); + switch (disposition.class) { + case 'supersession': + return { outcome: 'superseded', reason: failure.message, nextAction: 'none' }; + case 'permanent': + return { outcome: 'failed_previous_intact', reason: failure.message, nextAction: 'configure_credentials' }; + case 'operator_action_required': + return { outcome: 'blocked', reason: failure.message, nextAction: 'resolve_conflict' }; + case 'reconcile': + return { outcome: 'unknown', reason: failure.message, nextAction: 'none' }; + // 'degraded'/'target_*'/'blocked' are not reachable from a + // git_source_error classification today, but are grouped with + // 'transient' so this switch stays exhaustive if that changes. + case 'transient': + case 'degraded': + case 'target_permanent': + case 'target_transient': + case 'target_mutation_failed': + case 'blocked': + return { outcome: 'failed_previous_intact', reason: failure.message, nextAction: 'retry' }; + } + } + + private deriveReconcileResult(stackName: string): ReconcileResult { + const app = GitOpsStore.getInstance().getLiveDirectApplication(stackName); + if (!app) { + return GitSourceService.noApplicationResult(); + } + const projection = projectApplication(app.id, false); + if (projection.targetMode === 'not_applicable') { + if (projection.limitations.some((l) => l.code === 'application_row_missing')) { + return { + outcome: 'recovery_required', + reason: 'The application this reconcile was resolved from is no longer present.', + nextAction: 'view_target_results', + }; + } + return GitSourceService.noApplicationResult(); + } + return outcomeFromSourceFacet(projection.facets.source); + } + + /** + * Stop acting on a source without forgetting anything about it: no new + * fetch, acceptance, or dispatch until resumed (enforced by the + * suspended_at checks at the top of pullLocked/applyLockedBody, not by + * this method itself). Takes the per-stack mutex not to reject a + * concurrent fetch or apply, but because sourceSuspended interrupts and + * clears any in-flight operation's active state, which would corrupt a + * genuinely running apply's own terminal transition; a suspend queued + * behind one instead takes effect once that work settles. + * + * A refused suspend is surfaced as a real error rather than swallowed: + * silently no-op'ing here would leave an operator believing a source is + * suspended when it is not, which is the same false-safety failure this + * method exists to prevent. + */ + public async suspend(stackName: string, opts: { actor: string; reason?: string }): Promise { + return this.withStackLock(stackName, async () => { + const app = GitOpsStore.getInstance().getLiveDirectApplication(stackName); + if (!app) return GitSourceService.noApplicationResult(); + const envelope = this.gitopsEnvelope(crypto.randomUUID(), opts.actor, 'suspend'); + const reason = opts.reason?.trim() || 'Suspended by operator.'; + try { + GitOpsTransitions.getInstance().sourceSuspended(app.id, reason, envelope); + } catch (error) { + if (error instanceof GitOpsTransitionError) { + throw new GitSourceError('OPERATION_IN_FLIGHT', `Cannot suspend ${stackName}: ${error.message}`); + } + throw error; + } + return this.deriveReconcileResult(stackName); + }); + } + + /** + * Resume acting on a source. Does not itself fetch; the next scheduled + * poll, retry, or manual reconcile picks the source back up. + * + * Unlike suspend(), a refused resume is tolerated rather than surfaced. + * The result is read back from the row after the attempted write, so a + * resume that did not take (already not suspended, the application + * vanished, a transient persistence failure) still truthfully reports + * {outcome:'suspended', nextAction:'resume'} rather than a false + * "resumed". The caller cannot be told the source is unsuspended when it + * is not, so there is no false-safety risk to mirror suspend()'s rethrow. + */ + public async resume(stackName: string, opts: { actor: string }): Promise { + return this.withStackLock(stackName, async () => { + const app = GitOpsStore.getInstance().getLiveDirectApplication(stackName); + if (!app) return GitSourceService.noApplicationResult(); + const envelope = this.gitopsEnvelope(crypto.randomUUID(), opts.actor, 'resume'); + this.recordGitOps(stackName, 'source resume', () => { + GitOpsTransitions.getInstance().sourceUnsuspended(app.id, envelope); + }); + return this.deriveReconcileResult(stackName); + }); + } + + /** + * An explicit, operator-initiated re-evaluation: resolves the live + * application server-side rather than trusting a caller-supplied id, for + * callers that hold only a stack name, and drives a fresh fetch-intent + * reconcile through it. + * + * The 'retry' trigger is recorded on the durable attempt for audit and + * recovery. It does not yet change fetch behavior; a later permanent- + * failure gate can use it to authorize an explicit operator retry. + */ + public async retry(stackName: string, opts: { actor: string }): Promise { + const app = GitOpsStore.getInstance().getLiveDirectApplication(stackName); + if (!app) return GitSourceService.noApplicationResult(); + return this.reconcile({ + intent: 'fetch', + applicationId: app.id, + stackName, + trigger: 'retry', + actor: opts.actor, + }); + } + + /** + * Route an accepted generation to its target. Blueprint mode always + * blocks (BlueprintTargetAdapter; rollout orchestration does not exist + * yet). Direct mode has no separate generation-based promotion pipeline + * today, so it dispatches by driving the same reconcile()/apply path a + * manual or webhook apply already uses, translating the normalized + * ReconcileResult into the narrower dispatched/blocked shape a target + * adapter reports. + */ + public async dispatchAcceptedGeneration( + generation: AcceptedGeneration, + context: DispatchContext, + opts: { trigger: ReconcileTrigger; actor: string }, + ): Promise { + if (context.targetMode === 'blueprint') { + return new BlueprintTargetAdapter().dispatch(generation, context); + } + const app = GitOpsStore.getInstance().getApplication(generation.applicationId); + if (!app?.stack_name) { + return { status: 'blocked', reason: 'No Direct stack is bound to this application.' }; + } + const source = DatabaseService.getInstance().getGitSource(app.stack_name); + const result = await this.reconcile({ + intent: 'apply', + applicationId: generation.applicationId, + stackName: app.stack_name, + trigger: opts.trigger, + actor: opts.actor, + commitSha: generation.commitSha, + planFingerprint: generation.changePlanFingerprint ?? '', + deploy: source?.auto_deploy_on_apply ?? false, + }); + // 'converged' is not produced by reconcile() today (it requires + // target + health evidence this source-only path does not have), + // but it is a declared success member of ReconcileOutcome; treating + // only 'no_source_change' as success would silently misreport it as + // blocked the day a broader derivation starts emitting it. + if (result.outcome === 'no_source_change' || result.outcome === 'converged') { + return { status: 'dispatched' }; + } + return { status: 'blocked', reason: result.reason }; } /** @@ -2181,6 +3239,7 @@ export class GitSourceService { stackName: string, commitSha: string, opts: GitApplyOpts, + operationId?: string, ): Promise<{ applied: boolean; deployed: boolean; deployError?: string; recoveryId?: string }> { const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); const lock = await StackOpLockService.getInstance().runExclusive( @@ -2188,7 +3247,7 @@ export class GitSourceService { stackName, 'git_apply', opts.actor ?? 'system:git-source', - () => this.applyLocked(stackName, commitSha, opts), + () => this.applyLocked(stackName, commitSha, opts, operationId), getRegistryDeliveryLockContext(), ); if (!lock.ran) { @@ -2213,6 +3272,7 @@ export class GitSourceService { stackName: string, commitSha: string, opts: GitApplyOpts, + operationId?: string, ): Promise<{ applied: boolean; deployed: boolean; deployError?: string; recoveryId?: string }> { const started: { app: GitOpsApplicationRow | null; env: ReturnType | null; settled: boolean } = { app: null, @@ -2220,7 +3280,7 @@ export class GitSourceService { settled: false, }; try { - return await this.applyLockedBody(stackName, commitSha, opts, started); + return await this.applyLockedBody(stackName, commitSha, opts, started, operationId); } catch (e) { if (started.app && started.env && !started.settled) { const app = started.app; @@ -2242,11 +3302,18 @@ export class GitSourceService { commitSha: string, opts: GitApplyOpts, started: { app: GitOpsApplicationRow | null; env: ReturnType | null; settled: boolean }, + operationId?: string, ): Promise<{ applied: boolean; deployed: boolean; deployError?: string; recoveryId?: string }> { const diag = isDebugEnabled(); const db = DatabaseService.getInstance(); const src = db.getGitSource(stackName); if (!src) throw new GitSourceError('GIT_ERROR', 'No Git source configured for this stack.'); + // Same reasoning as pullLocked's guard: applyStarted's own suspension + // check is swallowed by recordGitOps once promotion is already + // underway, so stop the promotion before it starts, not after. + if (this.gitopsApplicationFor(stackName)?.suspended_at) { + throw new GitSourceError('OPERATION_IN_FLIGHT', `Reconciliation is suspended for ${stackName}.`); + } if (!src.pending_commit_sha || !src.pending_compose_content) { throw new GitSourceError('GIT_ERROR', 'No pending pull to apply. Fetch the source again.'); @@ -2304,7 +3371,16 @@ export class GitSourceService { sanitizeForLog(stackName), sanitizeForLog(commitSha.slice(0, 7)), ); } - const gitopsEnv = this.gitopsEnvelope(pending.operationId, actor, 'apply'); + // A caller that reserved a durable attempt for this apply passes its own + // operation id in, so the attempt and its gitops transitions + // (applyStarted/applied/applyFailed) share one identity. A caller that + // reserved none falls back to the fetch-time pending.operationId, as + // every caller did before reservation existed. The activity messages + // below reuse this same value, not pending.operationId directly, so + // they report the id the durable evidence for this apply actually + // carries rather than the earlier fetch attempt's. + const applyOperationId = operationId ?? pending.operationId; + const gitopsEnv = this.gitopsEnvelope(applyOperationId, actor, 'apply'); if (gitopsApp && gitopsGenerationId) { this.recordGitOps(stackName, 'apply start', () => { GitOpsTransitions.getInstance().applyStarted(gitopsApp.id, gitopsGenerationId, gitopsEnv); @@ -2342,8 +3418,14 @@ export class GitSourceService { pending.candidateRelPath, ); } - const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data'); - const candidateAbs = path.join(dataDir, 'git-managed', String(nodeId), stackName, pending.candidateRelPath); + const managedRoot = path.resolve(stackManagedRoot(stackName)); + const pathReason = validateCandidateRelPath(pending.candidateRelPath, managedRoot); + if (pathReason) throw new GitSourceError('GIT_ERROR', pathReason); + // Inline barrier at the access sink (CodeQL path-injection). + const candidateAbs = path.resolve(managedRoot, pending.candidateRelPath); + if (!candidateAbs.startsWith(managedRoot + path.sep)) { + throw new GitSourceError('GIT_ERROR', 'candidateRelPath escapes the managed root'); + } try { await fsPromises.access(candidateAbs); } catch (accessErr: unknown) { @@ -2411,7 +3493,7 @@ export class GitSourceService { { plan: publicPlan, planFingerprint: plan.fingerprint }, ); } - const blockedPlanActivity = `Git plan blocked for ${stackName} (${commitSha.slice(0, 7)}, op ${pending.operationId.slice(0, 8)}, plan ${plan.fingerprint.slice(0, 12)})`; + const blockedPlanActivity = `Git plan blocked for ${stackName} (${commitSha.slice(0, 7)}, op ${GitSourceService.shortOperationId(applyOperationId)}, plan ${plan.fingerprint.slice(0, 12)})`; if (plan.blocked) { this.upsertGitPlanDrift(stackName, plan); db.setGitSourceLastPlan(stackName, plan.fingerprint, 'blocked'); @@ -2550,7 +3632,7 @@ export class GitSourceService { this.recordGitActivity( stackName, 'git_apply_rolled_back', - `Git apply rolled back for ${stackName} (${commitSha.slice(0, 7)}, op ${pending.operationId.slice(0, 8)}, plan ${plan.fingerprint.slice(0, 12)})`, + `Git apply rolled back for ${stackName} (${commitSha.slice(0, 7)}, op ${GitSourceService.shortOperationId(applyOperationId)}, plan ${plan.fingerprint.slice(0, 12)})`, actor, 'warning', ); @@ -2559,7 +3641,7 @@ export class GitSourceService { this.recordGitActivity( stackName, 'git_apply_failed', - `Git apply failed for ${stackName} (${commitSha.slice(0, 7)}, op ${pending.operationId.slice(0, 8)}, plan ${plan.fingerprint.slice(0, 12)})`, + `Git apply failed for ${stackName} (${commitSha.slice(0, 7)}, op ${GitSourceService.shortOperationId(applyOperationId)}, plan ${plan.fingerprint.slice(0, 12)})`, actor, 'error', ); @@ -2572,9 +3654,15 @@ export class GitSourceService { this.recordGitActivity( stackName, 'git_apply', - `Git apply succeeded for ${stackName} (${commitSha.slice(0, 7)}, op ${pending.operationId.slice(0, 8)}, plan ${plan.fingerprint.slice(0, 12)})`, + `Git apply succeeded for ${stackName} (${commitSha.slice(0, 7)}, op ${GitSourceService.shortOperationId(applyOperationId)}, plan ${plan.fingerprint.slice(0, 12)})`, actor, ); + // Promotion has committed and rewritten the authoritative Compose + // files, so cached stats/statuses/project-name state is stale here + // whether or not a deploy follows. This must fire exactly once per + // successful promotion, from every trigger, not only the manual + // apply route (which used to invalidate here itself). + invalidateNodeCaches(nodeId); } else { throw new GitSourceError('PLAN_UNAVAILABLE', 'Pending update cannot be reviewed; pull again.'); } @@ -2658,6 +3746,12 @@ export class GitSourceService { recoverySvc.linkGateOrRetain(recoveryId, healthGateId); } console.log(`[GitSource] Applied and deployed ${stackName} at ${commitSha.slice(0, 7)}`); + // Fire-and-forget, matching the manual apply route's prior + // placement: the scan runs only after a successful deploy and + // must never delay or fail the apply response. + triggerPostDeployScan(stackName, nodeId).catch((err) => + console.error(`[Security] Post-deploy scan failed for ${sanitizeForLog(stackName)}:`, err), + ); return { applied: true, deployed: true, recoveryId }; } catch (e) { // R1: do not auto-compensate. Keep applied files and leave the @@ -2790,6 +3884,9 @@ export class GitSourceService { })() : null; + const encryptedCaBundle = this.resolveEncryptedCaBundle(input.caBundle, undefined); + const caBundlePem = this.decryptCaBundlePem(encryptedCaBundle); + // 1. Fetch from git BEFORE touching disk or DB. If the fetch // fails there is nothing to clean up. The onClone hook stages // the complete-project candidate inside the clone lifecycle. @@ -2798,15 +3895,16 @@ export class GitSourceService { const deliveryPrepId = getRegistryDeliveryContext()?.envelope.prepId; let fetched: FetchResult; const createFetchAuth = input.authType === 'token' - ? { token: input.token } + ? { token: input.token, caBundlePem } : createDeployKeyTrust ? { sshAuth: { privateKey: input.deployKey!.trim(), knownHostsEntry: createDeployKeyTrust.sshKnownHostsEntry, }, + caBundlePem, } - : { token: null }; + : { token: null, caBundlePem }; try { if (deliveryPrepId) { const restored = await this.restoreCreateFromPreparedGitCandidate( @@ -3032,6 +4130,7 @@ export class GitSourceService { encryptedDeployKey: createDeployKeyTrust?.encryptedDeployKey ?? null, sshKnownHostsEntry: createDeployKeyTrust?.sshKnownHostsEntry ?? null, sshHostKeyFingerprint: createDeployKeyTrust?.sshHostKeyFingerprint ?? null, + encryptedCaBundle, autoApplyOnWebhook: input.autoApplyOnWebhook, autoDeployOnApply: input.autoDeployOnApply, commitSha: fetched.commitSha, @@ -3106,6 +4205,7 @@ export class GitSourceService { encrypted_deploy_key: createDeployKeyTrust?.encryptedDeployKey ?? null, ssh_known_hosts_entry: createDeployKeyTrust?.sshKnownHostsEntry ?? null, ssh_host_key_fingerprint: createDeployKeyTrust?.sshHostKeyFingerprint ?? null, + encrypted_ca_bundle: encryptedCaBundle, auto_apply_on_webhook: input.autoApplyOnWebhook, auto_deploy_on_apply: input.autoDeployOnApply, last_applied_commit_sha: fetched.commitSha, @@ -3162,20 +4262,19 @@ export class GitSourceService { } rowInserted = true; - const operationId = crypto.randomUUID(); if (completeProjectManifest && materialization.value && recordedCreatePlan) { db.setGitSourceLastPlan(input.stackName, recordedCreatePlan.fingerprint, 'applied'); this.recordGitActivity( input.stackName, 'git_create', - `Git create succeeded for ${input.stackName} (${fetched.commitSha.slice(0, 7)}, op ${operationId.slice(0, 8)}, plan ${recordedCreatePlan.fingerprint.slice(0, 12)})`, + `Git create succeeded for ${input.stackName} (${fetched.commitSha.slice(0, 7)}, op ${GitSourceService.shortOperationId(gitopsOperationId)}, plan ${recordedCreatePlan.fingerprint.slice(0, 12)})`, 'system:git-source', ); } else { this.recordGitActivity( input.stackName, 'git_create', - `Git create succeeded for ${input.stackName} (${fetched.commitSha.slice(0, 7)}, op ${operationId.slice(0, 8)})`, + `Git create succeeded for ${input.stackName} (${fetched.commitSha.slice(0, 7)}, op ${GitSourceService.shortOperationId(gitopsOperationId)})`, 'system:git-source', ); } @@ -3348,6 +4447,75 @@ export class GitSourceService { } } + /** + * The candidate directory basenames (e.g. "candidate-") the boot + * sweep must not reap for this stack: a row still points at each one, so + * it is still needed no matter how old or how incomplete it looks on + * disk. Direct-mode only, matching the candidate/generation model itself. + */ + private claimedCandidateDirsFor(stackName: string): { dirs: Set; complete: boolean } { + const store = GitOpsStore.getInstance(); + const claimed = new Set(); + let complete = true; + const app = store.getLiveDirectApplication(stackName); + if (app) { + // candidate_generation_id names the currently staged candidate. + // accepted_generation_id is set by applySourceAcceptanceMutation + // and never cleared, so after an ordinary apply it names an + // already-promoted generation whose candidate directory has been + // moved away (harmless to check, just not load-bearing). It earns + // its place for the sourceAccepted-committed-but-targetApplied- + // not-yet-committed window, where the generation is accepted and + // genuinely still unpromoted on disk. Nothing calls sourceAccepted + // or targetApplied yet, so that is forward-looking coverage rather + // than dead code. + for (const generationId of [app.candidate_generation_id, app.accepted_generation_id]) { + if (!generationId) continue; + const generation = store.getGeneration(generationId); + if (generation) { + claimed.add(path.basename(generation.candidate_dir)); + } else { + complete = false; + console.warn( + `[GitSource] Generation claimant ${sanitizeForLog(generationId)} for ${sanitizeForLog(stackName)} could not be resolved during the sweep.`, + ); + } + } + try { + for (const generation of store.listGenerationsClaimedByUnsettledAttempts(app.id)) { + claimed.add(path.basename(generation.candidate_dir)); + } + } catch (e) { + complete = false; + console.warn( + `[GitSource] Could not read unsettled-attempt candidate claims for ${sanitizeForLog(stackName)} during the sweep:`, + e instanceof Error ? e.message : String(e), + ); + } + } + // The pending blob's own candidateRelPath is a third, independent + // claimant: it is written outside the transaction that mints a + // generation, so a candidate can be staged and recorded as pending + // with no generation row at all (fetchedInvalid) or with no live + // application to read a pointer from (a stack whose boot migration + // failed). A decode failure must not abort the sweep; it only means + // this extra claim is unavailable. + try { + const src = DatabaseService.getInstance().getGitSource(stackName); + if (src?.pending_compose_content) { + const pending = this.decodePendingCompose(src.pending_compose_content); + if (pending.candidateRelPath) claimed.add(path.basename(pending.candidateRelPath)); + } + } catch (e) { + complete = false; + console.warn( + `[GitSource] Could not read the pending candidate reference for ${sanitizeForLog(stackName)} while computing sweep claimants:`, + e instanceof Error ? e.message : String(e), + ); + } + return { dirs: claimed, complete }; + } + public async sweepOrphans(): Promise { const fsSvc = FileSystemService.getInstance(); const manifestSvc = GitProjectManifestService.getInstance(); @@ -3385,9 +4553,17 @@ export class GitSourceService { }); continue; } - await this.withStackLock(row.stack_name, () => - manifestSvc.sweepManagedArea(row.stack_name, { repoUrl: row.repo_url, branch: row.branch, stackExists: true }), - ); + await this.withStackLock(row.stack_name, () => { + const claims = this.claimedCandidateDirsFor(row.stack_name); + return manifestSvc.sweepManagedArea(row.stack_name, { + repoUrl: row.repo_url, + branch: row.branch, + stackExists: true, + candidateClaims: claims.complete + ? { complete: true, dirs: claims.dirs } + : { complete: false }, + }); + }); } catch (e) { console.error(`[GitManifest] sweep failed for ${row.stack_name}:`, (e as Error).message); } @@ -3469,81 +4645,221 @@ export class GitSourceService { // ─── Webhook-triggered pull ────────────────────────────────────────────── + /** Whether this delivery's effective intent requires deploy permission. */ + public webhookDeliveryRequiresDeploy(stackName: string, deliveryId?: string): boolean { + const source = DatabaseService.getInstance().getGitSource(stackName); + if (!source) return false; + const app = this.gitopsApplicationFor(stackName); + const started = app && deliveryId + ? GitOpsStore.getInstance().getStartedAttempt( + app.id, + deliveryKey('webhook', 'fetch', deliveryId), + ) + : undefined; + if (started) return GitSourceService.deliveryIntentFromStartedAttempt(started).deploy; + return source.auto_apply_on_webhook && source.auto_deploy_on_apply; + } + /** - * Invoked by the webhook dispatcher. Returns a short status string to - * record in webhook_executions. Enforces the per-source debounce. + * Invoked by the webhook dispatcher. A provider-scoped delivery id resolves + * redeliveries durably. Debounce still rate-limits new deliveries and is + * the only deduplication mechanism when no stable id is available. The + * caller must explicitly pass whether the principal is authorized to + * execute a requested deploy. */ - public async handleWebhookPull(stackName: string): Promise<{ status: 'success' | 'skipped' | 'error'; message: string }> { - // Run the whole critical section under a single lock acquisition so a - // concurrent fan-out (N webhooks for one push) serializes AND re-reads - // last_debounce_at after acquiring the lock. The first request stamps - // the window; every queued duplicate then sees the stamp and skips - // instead of cloning again. The debounce is still stamped only after a - // successful fetch, so a transient failure stays immediately retriable. - return this.withStackLock<{ status: 'success' | 'skipped' | 'error'; message: string }>(stackName, async () => { - const diag = isDebugEnabled(); - const db = DatabaseService.getInstance(); - const src = db.getGitSource(stackName); - if (!src) { - return { status: 'error', message: 'No Git source configured for this stack.' }; - } + public async handleWebhookPull( + stackName: string, + deployAuthorized: boolean, + deliveryId?: string, + ): Promise { + if (!deliveryId) return this.handleWebhookPullOnce(stackName, undefined, deployAuthorized); + const key = `${stackName}:${deliveryId}`; + const leader = this.inFlightWebhookDeliveries.get(key); + if (leader) return leader.promise; - const now = Date.now(); - if (src.last_debounce_at !== null && (now - src.last_debounce_at) < WEBHOOK_DEBOUNCE_MS) { - if (diag) console.log(`[GitSource:diag] webhook debounced stack=${stackName} age=${now - src.last_debounce_at}ms`); - return { status: 'skipped', message: 'Rate limited (debounced).' }; + const promise = this.handleWebhookPullOnce(stackName, deliveryId, deployAuthorized); + const entry = { promise }; + this.inFlightWebhookDeliveries.set(key, entry); + try { + return await promise; + } finally { + if (this.inFlightWebhookDeliveries.get(key) === entry) { + this.inFlightWebhookDeliveries.delete(key); } + } + } - let pullResult: PullResult; + private async handleWebhookPullOnce( + stackName: string, + deliveryId: string | undefined, + deployAuthorized: boolean, + ): Promise { + const actor = 'system:webhook'; + const diag = isDebugEnabled(); + const deliverySuffix = deliveryId ? ` (delivery ${sanitizeForLog(deliveryId)})` : ''; + const db = DatabaseService.getInstance(); + const src = db.getGitSource(stackName); + if (!src) return { status: 'error', message: 'No Git source configured for this stack.' }; + + const gitopsApp = this.gitopsApplicationFor(stackName); + const startedDelivery = gitopsApp && deliveryId + ? GitOpsStore.getInstance().getStartedAttempt( + gitopsApp.id, + deliveryKey('webhook', 'fetch', deliveryId), + ) + : undefined; + const existingDelivery = !!startedDelivery; + const now = Date.now(); + if (!existingDelivery && src.last_debounce_at !== null && (now - src.last_debounce_at) < WEBHOOK_DEBOUNCE_MS) { + if (diag) console.log(`[GitSource:diag] webhook debounced stack=${stackName} age=${now - src.last_debounce_at}ms`); + return { status: 'skipped', message: 'Rate limited (debounced).' }; + } + if (!existingDelivery && gitopsApp?.suspended_at) { + return { status: 'skipped', message: 'Reconciliation is suspended for this source.' }; + } + if (!gitopsApp) { try { - pullResult = await this.pullLocked(stackName, 'system:webhook'); + this.refuseUntrackedSource(stackName, actor, 'fetch', false); } catch (e) { - const msg = e instanceof GitSourceError ? `${e.code}: ${e.message}` : (e as Error).message; - const scrubbed = scrubCredentials(msg); - this.recordGitActivity(stackName, 'git_pull_failed', `Git pull failed for ${stackName}`, 'system:webhook', 'error'); - console.error(`[GitSource] Webhook pull failed for ${sanitizeForLog(stackName)}: ${sanitizeForLog(scrubbed)}`); - return { status: 'error', message: scrubbed }; + const msg = e instanceof GitSourceError ? e.message : (e as Error).message; + console.warn(`[GitSource] Webhook delivery skipped for ${sanitizeForLog(stackName)}: ${sanitizeForLog(msg)}`); + return { status: 'skipped', message: msg }; } + return { status: 'error', message: 'GitOps tracking is unavailable for this source.' }; + } + + let deliveryIntent = GitSourceService.deliveryIntent( + src.auto_apply_on_webhook, + src.auto_deploy_on_apply, + ); + if (startedDelivery) { try { - // Only burn the debounce window once the fetch actually produced - // something. A transient network failure should be retriable - // immediately rather than locked out for the debounce interval. - db.touchGitSourceDebounce(stackName); - if (!pullResult.validation.ok) { - // Webhooks are unattended, so always leave a server-side - // breadcrumb; the caller only sees the HTTP status. - console.warn(`[GitSource] Webhook pull validation failed for ${sanitizeForLog(stackName)}: ${sanitizeForLog(pullResult.validation.error ?? 'unknown')}`); - return { status: 'error', message: `Validation failed: ${pullResult.validation.error}` }; - } - - if (!src.auto_apply_on_webhook) { - if (diag) console.log(`[GitSource:diag] webhook pending-only stack=${stackName} sha=${pullResult.commitSha.slice(0, 7)}`); - return { status: 'success', message: `Pending update ready at ${pullResult.commitSha.slice(0, 7)}.` }; - } - - const applied = await this.applyWithSharedLock(stackName, pullResult.commitSha, { - deploy: src.auto_deploy_on_apply, - actor: 'system:webhook', - requirePlanFingerprint: false, - }); - if (applied.deployError) { - // Apply wrote to disk but deploy failed. Surface it so the - // webhook_executions row records a degraded outcome instead - // of a clean success. - return { status: 'error', message: `Applied commit ${pullResult.commitSha.slice(0, 7)} but deploy failed: ${applied.deployError}` }; - } - const suffix = applied.deployed ? ' and deployed' : ''; - return { status: 'success', message: `Applied commit ${pullResult.commitSha.slice(0, 7)}${suffix}.` }; + deliveryIntent = GitSourceService.deliveryIntentFromStartedAttempt(startedDelivery); } catch (e) { - const msg = e instanceof GitSourceError ? `${e.code}: ${e.message}` : (e as Error).message; - const scrubbed = scrubCredentials(msg); - // Unattended path: record the failure server-side so an operator - // can diagnose without diag mode, since the Git provider only - // logs the HTTP status. - console.error(`[GitSource] Webhook pull failed for ${sanitizeForLog(stackName)}: ${sanitizeForLog(scrubbed)}`); - return { status: 'error', message: scrubbed }; + console.error( + '[GitSource] Could not recover webhook delivery intent for %s%s:', + sanitizeForLog(stackName), + deliverySuffix, + e instanceof Error ? e.message : String(e), + ); + return { status: 'error', message: 'Could not recover the original webhook delivery intent.' }; } - }); + } + if (deliveryIntent.deploy && !deployAuthorized) { + return { status: 'error', message: 'Deploy permission is required for this webhook delivery.' }; + } + + let pullResult: PullResult | undefined; + let fetchResult: ReconcileResult | undefined; + let replayedFetch: boolean; + try { + const request: ReconcileRequest = { + intent: 'fetch', + applicationId: gitopsApp.id, + stackName, + trigger: 'webhook', + actor, + deliveryId, + }; + const submission = await this.submitExecution( + this.inFlightFetches, + request, + this.doPullWork(stackName, actor, gitopsApp.id), + (outcome) => this.fetchExecutionResult(stackName, outcome), + coalesceKey(request), + deliveryId ? deliveryIntent : undefined, + ); + fetchResult = GitSourceService.resultFromSubmission(submission); + replayedFetch = submission.kind === 'replayed'; + if (submission.kind === 'executed') { + if (submission.execution.status === 'rejected') throw submission.execution.reason; + pullResult = submission.execution.value; + } + } catch (e) { + return this.webhookFailure(stackName, deliverySuffix, 'pull', e); + } + + if (fetchResult && !pullResult) { + const replayResult = GitSourceService.webhookResultFromReconcile(fetchResult); + if (replayResult.status !== 'success') return replayResult; + } + if (!replayedFetch) db.touchGitSourceDebounce(stackName); + if (pullResult && !pullResult.validation.ok) { + console.warn(`[GitSource] Webhook pull validation failed for ${sanitizeForLog(stackName)}: ${sanitizeForLog(pullResult.validation.error ?? 'unknown')}`); + return { status: 'error', message: `Validation failed: ${pullResult.validation.error}` }; + } + + const commitSha = pullResult?.commitSha ?? fetchResult?.commitSha; + if (!commitSha) { + return { status: 'error', message: fetchResult?.reason ?? 'The fetch completed without a candidate commit.' }; + } + const currentSource = db.getGitSource(stackName); + if (!currentSource) return { status: 'error', message: 'The Git source configuration was removed during reconciliation.' }; + if (!deliveryIntent.autoApply) { + if (diag) console.log(`[GitSource:diag] webhook pending-only stack=${stackName} sha=${commitSha.slice(0, 7)}`); + return { status: 'success', message: `Pending update ready at ${commitSha.slice(0, 7)}.` }; + } + + try { + const request: ReconcileRequest = { + intent: 'apply', + applicationId: gitopsApp.id, + stackName, + trigger: 'webhook', + actor, + deliveryId, + commitSha, + planFingerprint: '', + deploy: deliveryIntent.deploy, + }; + const submission = await this.submitExecution( + this.inFlightApplies, + request, + (operationId) => this.withStackLock(stackName, async () => { + this.assertLiveApplication(stackName, gitopsApp.id); + return this.applyWithSharedLock(stackName, commitSha, { + deploy: deliveryIntent.deploy, + actor, + requirePlanFingerprint: false, + }, operationId); + }), + (outcome) => this.applyExecutionResult(stackName, outcome), + GitSourceService.applyExecutionKey(request, false), + ); + if (submission.kind === 'replayed') { + return GitSourceService.webhookResultFromReconcile(submission.result); + } + if (submission.execution.status === 'rejected') throw submission.execution.reason; + const applied = submission.execution.value; + if (applied.deployError) { + return { status: 'error', message: `Applied commit ${commitSha.slice(0, 7)} but deploy failed: ${applied.deployError}` }; + } + const suffix = applied.deployed ? ' and deployed' : ''; + return { status: 'success', message: `Applied commit ${commitSha.slice(0, 7)}${suffix}.` }; + } catch (e) { + return this.webhookFailure(stackName, deliverySuffix, 'apply', e); + } + } + + private webhookFailure(stackName: string, deliverySuffix: string, phase: 'pull' | 'apply', error: unknown): WebhookPullResult { + const msg = error instanceof GitSourceError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : String(error); + const scrubbed = scrubCredentials(msg); + console.error(`[GitSource] Webhook ${phase} failed for ${sanitizeForLog(stackName)}${deliverySuffix}: ${sanitizeForLog(scrubbed)}`); + return { status: 'error', message: scrubbed }; + } + + private static webhookResultFromReconcile(result: ReconcileResult): WebhookPullResult { + switch (result.outcome) { + case 'converged': + case 'no_source_change': + case 'candidate_already_fetched': + case 'pending_review': + return { status: 'success', message: result.reason }; + case 'suspended': + return { status: 'skipped', message: result.reason }; + default: + return { status: 'error', message: result.reason }; + } } // ─── Change plan helpers ───────────────────────────────────────────────── @@ -3758,12 +5074,15 @@ export class GitSourceService { input: CreateStackFromGitInput, ): Promise<{ prepId: string; sourceHash: string }> { const materialization: { value: MaterializationResult | null } = { value: null }; + const encryptedCaBundle = this.resolveEncryptedCaBundle(input.caBundle, undefined); + const caBundlePem = this.decryptCaBundlePem(encryptedCaBundle); const fetched = await this.fetchFromGit({ repoUrl: input.repoUrl, branch: input.branch, composePaths: input.composePaths, envPath: input.syncEnv ? input.envPath : null, token: input.token, + caBundlePem, onClone: async (cloneDir, commitSha, envContent) => { materialization.value = await this.buildMaterialization( input.stackName, diff --git a/backend/src/services/MeshProxyTunnelDialer.ts b/backend/src/services/MeshProxyTunnelDialer.ts index c52578d1..38b254fb 100644 --- a/backend/src/services/MeshProxyTunnelDialer.ts +++ b/backend/src/services/MeshProxyTunnelDialer.ts @@ -11,6 +11,7 @@ import { PilotMetrics } from './PilotMetrics'; import type { MeshActivityType } from './MeshService'; import { LicenseService } from './LicenseService'; import { PROXY_TIER_HEADER } from './license-headers'; +import { assertSafeOutboundUrl, safeOutboundLookup } from '../utils/outboundTarget'; /** * Central-side dialer for proxy-mode mesh tunnels. @@ -247,7 +248,9 @@ export class MeshProxyTunnelDialer extends EventEmitter { const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); let ws: WebSocket; try { + await assertSafeOutboundUrl(target.apiUrl); ws = new WebSocket(wsUrl, { + lookup: safeOutboundLookup, headers: { Authorization: `Bearer ${target.apiToken}`, [PROXY_TIER_HEADER]: proxyHeaders.tier, diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index ce995088..6a550c18 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -20,6 +20,7 @@ import { lookupContainerIp } from '../mesh/containerLookup'; import { STREAM_PENDING_DATA_MAX_BYTES } from '../pilot/protocol'; import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog'; import { isDebugEnabled } from '../utils/debug'; +import { safeRemoteFetch } from '../utils/outboundTarget'; import { isPathWithinBase, isValidStackName, isValidRelativeStackPath } from '../utils/validation'; import { getErrorMessage } from '../utils/errors'; import { PORT as SENCHO_LISTEN_PORT } from '../helpers/constants'; @@ -2518,12 +2519,12 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } } - return await fetch(url, { + return await safeRemoteFetch(url, { method, headers, body: bodyToSend === undefined ? undefined : JSON.stringify(bodyToSend), signal: AbortSignal.timeout(timeoutMs), - }); + }, target.trustedLoopback); } /** diff --git a/backend/src/services/NodeRegistry.ts b/backend/src/services/NodeRegistry.ts index 2126990d..7d3ce609 100644 --- a/backend/src/services/NodeRegistry.ts +++ b/backend/src/services/NodeRegistry.ts @@ -4,6 +4,11 @@ import { EventEmitter } from 'events'; import { DatabaseService, Node } from './DatabaseService'; import { fetchRemoteMeta, OFFLINE_META, RemoteMeta } from './CapabilityRegistry'; import { PilotTunnelManager } from './PilotTunnelManager'; +import { assertSafeOutboundUrl, safeAxiosTransport } from '../utils/outboundTarget'; + +export type ProxyTarget = + | { apiUrl: string; apiToken: ''; trustedLoopback: true } + | { apiUrl: string; apiToken: string; trustedLoopback: false }; /** * NodeRegistry: Manages connections for multiple nodes. @@ -105,18 +110,18 @@ export class NodeRegistry extends EventEmitter { * bridge; the bridge strips the bearer token and re-authenticates * implicitly via the pre-verified tunnel socket. */ - public getProxyTarget(nodeId: number): { apiUrl: string; apiToken: string } | null { + public getProxyTarget(nodeId: number): ProxyTarget | null { const node = DatabaseService.getInstance().getNode(nodeId); if (!node || node.type !== 'remote') return null; if (node.mode === 'pilot_agent') { const loopbackUrl = PilotTunnelManager.getInstance().getLoopbackUrl(nodeId); if (!loopbackUrl) return null; - return { apiUrl: loopbackUrl, apiToken: '' }; + return { apiUrl: loopbackUrl, apiToken: '', trustedLoopback: true }; } if (!node.api_url || !node.api_token) return null; - return { apiUrl: node.api_url, apiToken: node.api_token }; + return { apiUrl: node.api_url, apiToken: node.api_token, trustedLoopback: false }; } /** @@ -127,7 +132,7 @@ export class NodeRegistry extends EventEmitter { public async fetchMetaForNode(nodeId: number): Promise { const target = this.getProxyTarget(nodeId); if (!target) return { ...OFFLINE_META }; - return fetchRemoteMeta(target.apiUrl, target.apiToken); + return fetchRemoteMeta(target.apiUrl, target.apiToken, target.trustedLoopback); } /** @@ -226,10 +231,16 @@ export class NodeRegistry extends EventEmitter { const baseUrl = node.api_url.replace(/\/$/, ''); const headers = { Authorization: `Bearer ${node.api_token}` }; + const requestConfig = { + ...safeAxiosTransport(false), + headers, + timeout: 8000, + }; try { + await assertSafeOutboundUrl(baseUrl); // Step 1: Verify auth. A 401 here means wrong token - surface that clearly. - const authRes = await axios.get(`${baseUrl}/api/auth/check`, { headers, timeout: 8000 }); + const authRes = await axios.get(`${baseUrl}/api/auth/check`, requestConfig); if (authRes.status !== 200) throw new Error(`Unexpected status ${authRes.status}`); db.updateNodeStatus(node.id, 'online'); @@ -237,9 +248,9 @@ export class NodeRegistry extends EventEmitter { // Step 2: Fetch Docker stats in parallel. Use allSettled so a slow or missing // endpoint doesn't fail the whole test - each field falls back to '-' gracefully. const [statsResult, sysResult, imagesResult, metaResult] = await Promise.allSettled([ - axios.get(`${baseUrl}/api/stats`, { headers, timeout: 8000 }), - axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }), - axios.get(`${baseUrl}/api/system/images`, { headers, timeout: 8000 }), + axios.get(`${baseUrl}/api/stats`, requestConfig), + axios.get(`${baseUrl}/api/system/stats`, requestConfig), + axios.get(`${baseUrl}/api/system/images`, requestConfig), fetchRemoteMeta(baseUrl, node.api_token!), ]); diff --git a/backend/src/services/PolicyEnforcement.ts b/backend/src/services/PolicyEnforcement.ts index 8ac9cddc..a594a0e4 100644 --- a/backend/src/services/PolicyEnforcement.ts +++ b/backend/src/services/PolicyEnforcement.ts @@ -74,6 +74,20 @@ export interface PolicyEnforcementResult { trivyMissing?: boolean; } +/** + * Candidate (pre-acceptance) policy outcome. Unlike the deploy-time gate, + * which deliberately fails open when the scanner is unavailable so an + * operator is never blocked from deploying, an unresolvable scanner state + * here is its own outcome: automatic source acceptance must not read + * `unavailable` as `allowed`, or a GitOps source could accept a candidate + * nothing actually proved safe. + */ +export type CandidatePolicyEvaluation = { policy?: ScanPolicy } & ( + | { status: 'allowed' } + | { status: 'blocked'; violations: PolicyViolation[] } + | { status: 'unavailable'; reason: string } +); + const TRIVY_MISSING_NOTIFY_COOLDOWN_MS = 60 * 60 * 1000; // Growth bounded by configured-policy fanout (only stacks with an enabled // block_on_deploy policy can land here), not by total stack churn. Cleared @@ -472,3 +486,50 @@ export async function enforcePolicyForImageRefs( ); return { ok: false, bypassed: false, policy, violations }; } + +/** + * Tri-state candidate evaluation for GitOps source acceptance, built on the + * same evaluator the deploy-time gate uses, with the candidate's own image + * refs supplied directly rather than read from disk. Has side effects: + * writes a policy.bypass/policy.suppression_pass audit row when applicable, + * and may dispatch the once-per-hour Trivy-missing operator notification. + */ +export async function evaluateCandidatePolicy( + stackName: string, + nodeId: number, + imageRefs: string[], + opts: PolicyEnforcementOptions, +): Promise { + const result = await enforcePolicyForImageRefs(stackName, nodeId, imageRefs, { + ...opts, + // Undefaulted, these attribute the audit row to a deploy path + // (enforcePolicyForImageRefs's own default), which never happened + // for a pre-acceptance candidate. + auditMethod: opts.auditMethod ?? 'POST', + auditPath: opts.auditPath ?? `/api/stacks/${stackName}/git-source/candidate`, + // The deploy gate silently skips an unscannable ref (fail-open, + // since it must never block an operator's deploy on its own + // inability to evaluate). Candidate evaluation is the opposite: an + // unscannable ref must surface as evidence, not vanish, so it can be + // told apart from a genuinely clean scan below. + }, undefined, true); + // Only trivyMissing forgoes bypass consideration below it because it is + // the one path that returns bypassed: false unconditionally; every other + // branch of the shared evaluator already honors opts.bypass itself. + if (result.trivyMissing) { + if (opts.bypass) return { status: 'allowed', policy: result.policy }; + return { status: 'unavailable', policy: result.policy, reason: 'Vulnerability scanner is unavailable' }; + } + if (!result.ok) { + // A violation with no `error` is a genuine scanned policy match; one + // with `error` set is an invalid ref, a scan failure, or an + // evaluation failure -- evidence Sencho could not prove either way, + // not a proven violation. All-unproven must not read as `blocked`. + const hasGenuineViolation = result.violations.some((v) => !v.error); + if (!hasGenuineViolation) { + return { status: 'unavailable', policy: result.policy, reason: 'Candidate could not be fully evaluated' }; + } + return { status: 'blocked', policy: result.policy, violations: result.violations }; + } + return { status: 'allowed', policy: result.policy }; +} diff --git a/backend/src/services/RegistryDeliveryReconciler.ts b/backend/src/services/RegistryDeliveryReconciler.ts index 68290c5c..722bcb66 100644 --- a/backend/src/services/RegistryDeliveryReconciler.ts +++ b/backend/src/services/RegistryDeliveryReconciler.ts @@ -7,8 +7,9 @@ import type { RegistryDeliveryEvidencePage } from '../types/registryDeliveryEvid import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { DatabaseService } from './DatabaseService'; -import { NodeRegistry } from './NodeRegistry'; +import { NodeRegistry, type ProxyTarget } from './NodeRegistry'; import { PilotTunnelManager } from './PilotTunnelManager'; +import { safeAxiosTransport } from '../utils/outboundTarget'; const RECONCILE_INTERVAL_MS = 5 * 60 * 1000; const RECONCILE_INITIAL_DELAY_MS = 30_000; @@ -140,7 +141,7 @@ export class RegistryDeliveryReconciler { } private async fetchEvidencePage( - target: { apiUrl: string; apiToken: string }, + target: ProxyTarget, cursor: number, limit: number, ): Promise { @@ -151,6 +152,7 @@ export class RegistryDeliveryReconciler { } const res = await axios.get(`${base}/api/registry-delivery/evidence`, { + ...safeAxiosTransport(target.trustedLoopback), headers, params: { cursor, limit }, timeout: 30_000, diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 032b6be2..45a3906a 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -22,10 +22,11 @@ import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; +import { safeRemoteFetch } from '../utils/outboundTarget'; import { formatNoTargetError } from '../utils/remoteTarget'; import { sanitizeForLog } from '../utils/safeLog'; import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, type SnapshotNodeData } from '../utils/snapshot-capture'; -import { NodeRegistry } from './NodeRegistry'; +import { NodeRegistry, type ProxyTarget } from './NodeRegistry'; import { NotificationService } from './NotificationService'; import TrivyService from './TrivyService'; import type { ScanAllNodeImagesResult } from './TrivyService'; @@ -992,7 +993,7 @@ export class SchedulerService { } const startTime = Date.now(); try { - const response = await fetch(`${baseUrl}/api/auto-update/execute`, { + const response = await safeRemoteFetch(`${baseUrl}/api/auto-update/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1001,7 +1002,7 @@ export class SchedulerService { }, body: JSON.stringify({ target }), signal: AbortSignal.timeout(300_000), // 5 minute timeout for long updates - }); + }, proxyTarget.trustedLoopback); if (!response.ok) { throw new Error(this.remoteProxyFailureMessage(nodeId, await this.remoteResponseDetail(response))); @@ -1027,7 +1028,7 @@ export class SchedulerService { } const startTime = Date.now(); try { - const response = await fetch(`${baseUrl}/api/auto-update/execute`, { + const response = await safeRemoteFetch(`${baseUrl}/api/auto-update/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1036,7 +1037,7 @@ export class SchedulerService { }, body: JSON.stringify({ targets }), signal: AbortSignal.timeout(300_000), - }); + }, proxyTarget.trustedLoopback); // Older remotes only accept { target }. Fall back to one call per // stack so mixed-version fleets still complete the label schedule. @@ -1109,7 +1110,7 @@ export class SchedulerService { } } - private requireRemoteProxyTarget(nodeId: number): { apiUrl: string; apiToken: string } { + private requireRemoteProxyTarget(nodeId: number): ProxyTarget { const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId); if (!proxyTarget) { throw new Error(this.remoteProxyFailureMessage(nodeId, this.noProxyTargetDetail(nodeId))); @@ -1187,13 +1188,13 @@ export class SchedulerService { const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); try { - const response = await fetch(`${baseUrl}/api/containers?all=true`, { + const response = await safeRemoteFetch(`${baseUrl}/api/containers?all=true`, { headers: { 'Authorization': `Bearer ${proxyTarget.apiToken}`, [PROXY_TIER_HEADER]: proxyHeaders.tier, }, signal: AbortSignal.timeout(60_000), - }); + }, proxyTarget.trustedLoopback); if (!response.ok) { throw new Error(this.remoteProxyFailureMessage(nodeId, await this.remoteResponseDetail(response))); } @@ -1218,7 +1219,7 @@ export class SchedulerService { const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); try { - const response = await fetch( + const response = await safeRemoteFetch( `${baseUrl}/api/containers/${encodeURIComponent(containerId)}/${action}`, { method: 'POST', @@ -1229,6 +1230,7 @@ export class SchedulerService { }, signal: AbortSignal.timeout(300_000), }, + proxyTarget.trustedLoopback, ); if (!response.ok) { throw new Error(this.remoteProxyFailureMessage(nodeId, await this.remoteResponseDetail(response))); @@ -1260,7 +1262,7 @@ export class SchedulerService { if (!augmented.ok) { throw new Error(augmented.error); } - const response = await fetch(`${baseUrl}/api/stacks/${routeSuffix}`, { + const response = await safeRemoteFetch(`${baseUrl}/api/stacks/${routeSuffix}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1270,7 +1272,7 @@ export class SchedulerService { }, body: JSON.stringify(augmented.body), signal: AbortSignal.timeout(300_000), - }); + }, proxyTarget.trustedLoopback); if (!response.ok) { throw new Error(this.remoteProxyFailureMessage(nodeId, await this.remoteResponseDetail(response))); } diff --git a/backend/src/services/SecretsService.ts b/backend/src/services/SecretsService.ts index 927cf2d5..4c55f5e1 100644 --- a/backend/src/services/SecretsService.ts +++ b/backend/src/services/SecretsService.ts @@ -9,6 +9,7 @@ import { resolveAllEnvFilePaths } from '../routes/stacks'; import { getErrorMessage } from '../utils/errors'; import { formatNoTargetError } from '../utils/remoteTarget'; import { isDebugEnabled } from '../utils/debug'; +import { safeRemoteFetch } from '../utils/outboundTarget'; export type SecretKv = Record; export type DiffStatus = 'added' | 'changed' | 'removed' | 'unchanged'; @@ -225,10 +226,10 @@ async function resolveEnvFileRemote(node: Node, stackName: string, basename: str const baseUrl = target.apiUrl.replace(/\/$/, ''); const headers: Record = {}; if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; - const res = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/envs`, { + const res = await safeRemoteFetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/envs`, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); + }, target.trustedLoopback); if (!res.ok) { if (res.status === 404 && basename === '.env') { return { absolutePath: '.env' }; @@ -268,10 +269,10 @@ async function readEnvRemote(node: Node, stackName: string, absolutePath: string if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; const url = new URL(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`); if (absolutePath !== '.env') url.searchParams.set('file', absolutePath); - const res = await fetch(url.toString(), { + const res = await safeRemoteFetch(url.toString(), { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); + }, target.trustedLoopback); if (res.status === 404) return ''; if (!res.ok) throw new Error(`failed to read env (HTTP ${res.status})`); return await res.text(); @@ -287,12 +288,12 @@ async function writeEnvRemote(node: Node, stackName: string, absolutePath: strin if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; const url = new URL(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`); if (absolutePath !== '.env') url.searchParams.set('file', absolutePath); - const res = await fetch(url.toString(), { + const res = await safeRemoteFetch(url.toString(), { method: 'PUT', headers, body: JSON.stringify({ content }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); + }, target.trustedLoopback); if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`failed to write env (HTTP ${res.status}${body ? ': ' + body.slice(0, 200) : ''})`); diff --git a/backend/src/services/SelfIdentityService.ts b/backend/src/services/SelfIdentityService.ts index e134bb01..29454db5 100644 --- a/backend/src/services/SelfIdentityService.ts +++ b/backend/src/services/SelfIdentityService.ts @@ -1,5 +1,10 @@ import fs from 'fs/promises'; import DockerController from './DockerController'; +import { classifyBuildChannel, isSenchoDevRepository, type BuildChannel } from '../helpers/selfUpdateCompose'; +import { defaultInspectImage } from './selfDevBuildDetect'; +import { parseImageRef, selectLocalRepoDigest } from './registry-api'; +import { getSenchoVersion } from './CapabilityRegistry'; +import { withTimeout } from '../utils/withTimeout'; /** * Identifies the Docker resources that belong to the running Sencho container @@ -20,17 +25,32 @@ import DockerController from './DockerController'; * stays in its empty state, every `isOwn*()` returns false, and today's * behavior is preserved. */ +/** Canonical runtime build identity of the running Sencho container. */ +export interface BuildInfo { + version: string | null; + channel: BuildChannel; + /** The image reference the running container was started with, null when unknown. */ + imageRef: string | null; + /** Running image sha256 hex (no prefix), null when unknown. */ + imageId: string | null; + /** Validated registry digest or pinned `dev-` tag, null when unknown. */ + revision: string | null; +} + class SelfIdentityService { private static instance: SelfIdentityService; private containerId: string | null = null; private containerName: string | null = null; private composeProjectName: string | null = null; private imageIdHex: string | null = null; + private imageRef: string | null = null; + private revision: string | null = null; private networkIds = new Set(); private networkNames = new Set(); private volumeNames = new Set(); private initialized = false; private initializePromise: Promise | null = null; + private enrichmentPromise: Promise | null = null; public static getInstance(): SelfIdentityService { if (!SelfIdentityService.instance) { @@ -58,6 +78,14 @@ class SelfIdentityService { this.containerName = (info.Name || '').replace(/^\//, '') || null; this.composeProjectName = info.Config?.Labels?.['com.docker.compose.project'] ?? null; this.imageIdHex = SelfIdentityService.stripSha(info.Image ?? '') || null; + this.imageRef = info.Config?.Image ?? null; + // Bounded revision enrichment runs detached so it never blocks the callers + // awaiting initialize() (Docker event monitoring, resources discovery). Core + // identity above is already captured; enrichment only adds the registry + // digest / pinned dev- and is failure-isolated. The promise is retained + // so a reader that needs the settled revision can await it (see + // whenRevisionResolved) instead of observing a transient null. + this.enrichmentPromise = this.enrichRevision(this.imageRef, this.imageIdHex); const nets = info.NetworkSettings?.Networks ?? {}; for (const [name, net] of Object.entries(nets)) { @@ -118,6 +146,64 @@ class SelfIdentityService { } } + /** + * Canonical runtime build identity. All fields are captured fields or derived + * synchronously from them; this read never triggers a Docker call. `revision` + * is populated by the detached enrichment step fired during initialize() and + * reads null until that resolves (or if it fails). + */ + getBuildInfo(): BuildInfo { + return { + version: getSenchoVersion(), + channel: this.imageRef ? classifyBuildChannel(this.imageRef) : 'unknown', + imageRef: this.imageRef, + imageId: this.imageIdHex, + revision: this.revision, + }; + } + + /** + * Resolves once the detached revision enrichment has settled (success or + * failure), or immediately when none was started. Awaiting cannot hang or + * throw (enrichment is bounded and failure-isolated). A reader that needs + * the final `revision` awaits this before getBuildInfo() so a successful + * response never freezes a transient null. + */ + async whenRevisionResolved(): Promise { + if (this.enrichmentPromise) await this.enrichmentPromise; + } + + /** + * Resolve the immutable revision from the running image. For a dev-repo image + * carrying a pinned `dev-` tag, the tag itself is the revision. Otherwise + * the running image's `RepoDigests` are inspected for a digest matching the + * running reference. Any failure (inspect rejection, timeout, no matching + * digest) leaves `revision` null; enrichment never throws to the caller. + */ + private async enrichRevision(imageRef: string | null, imageIdHex: string | null): Promise { + try { + let revision: string | null = null; + if (imageRef && isSenchoDevRepository(imageRef)) { + const tag = parseImageRef(imageRef)?.tag; + if (tag && /^dev-[0-9a-f]{7,40}$/.test(tag)) revision = tag; + } + if (!revision && imageRef && imageIdHex) { + revision = await this.resolveDigestRevision(imageRef, imageIdHex); + } + this.revision = revision; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn('[SelfIdentity] build revision enrichment failed:', message); + } + } + + private async resolveDigestRevision(imageRef: string, imageIdHex: string): Promise { + const parsed = parseImageRef(imageRef); + if (!parsed) return null; + const inspected = await withTimeout(defaultInspectImage(imageIdHex), 2000, 'build revision inspect'); + return selectLocalRepoDigest(inspected.RepoDigests ?? [], parsed); + } + /** True when the given container ID or name matches the running Sencho container. Accepts short or full IDs. */ isOwnContainer(idOrName: string): boolean { if (!idOrName) return false; @@ -205,11 +291,14 @@ class SelfIdentityService { this.containerName = null; this.composeProjectName = null; this.imageIdHex = null; + this.imageRef = null; + this.revision = null; this.networkIds.clear(); this.networkNames.clear(); this.volumeNames.clear(); this.initialized = false; this.initializePromise = null; + this.enrichmentPromise = null; } private static stripSha(s: string): string { diff --git a/backend/src/services/WebhookService.ts b/backend/src/services/WebhookService.ts index baa013a9..1ecf50b5 100644 --- a/backend/src/services/WebhookService.ts +++ b/backend/src/services/WebhookService.ts @@ -9,6 +9,7 @@ import { HealthGateService } from './HealthGateService'; import { LicenseService } from './LicenseService'; import { PROXY_TIER_HEADER } from './license-headers'; import { NodeRegistry } from './NodeRegistry'; +import { safeRemoteFetch } from '../utils/outboundTarget'; import { getErrorMessage } from '../utils/errors'; import { redactSensitiveText } from '../utils/safeLog'; import { isValidStackName } from '../utils/validation'; @@ -19,6 +20,7 @@ type ExecutionResult = { success: boolean; error?: string; duration_ms: number } type ExecutionStatus = 'success' | 'failure'; const REMOTE_WEBHOOK_REQUEST_TIMEOUT_MS = 30_000; +const MAX_PROVIDER_DELIVERY_ID_LENGTH = 256; // Maps a webhook lifecycle action to the per-stack lock action. 'pull' updates, // so it locks as 'update'; 'git-pull' is excluded (it locks inside GitSourceService). @@ -95,6 +97,7 @@ export class WebhookService { action: string, triggerSource: string | null, atomic?: boolean, + deliveryId?: string, ): Promise { if (webhook.id === undefined) { throw new Error('Webhook must be loaded from the database before execution'); @@ -109,11 +112,34 @@ export class WebhookService { return { success: false, error, duration_ms: 0 }; } + const scopedDeliveryId = action === 'git-pull' + ? WebhookService.scopedDeliveryId( + DatabaseService.getInstance().getGlobalSettings().delivery_source_id, + webhookId, + deliveryId, + ) + : undefined; + if (node.type === 'remote') { - return this.executeRemote(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic); + return this.executeRemote(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic, scopedDeliveryId); } - return this.executeLocal(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic); + return this.executeLocal(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic, scopedDeliveryId); + } + + /** Stable external identity, isolated to one configured webhook producer. */ + private static scopedDeliveryId( + deliverySourceId: string | undefined, + webhookId: number, + deliveryId: string | undefined, + ): string | undefined { + const normalized = deliveryId?.trim(); + if (!normalized) return undefined; + if (!deliverySourceId) throw new Error('Webhook delivery source identity is not configured'); + const bounded = normalized.length <= MAX_PROVIDER_DELIVERY_ID_LENGTH + ? normalized + : `sha256:${crypto.createHash('sha256').update(normalized).digest('hex')}`; + return `webhook:${deliverySourceId}:${webhookId}:${bounded}`; } public maskSecret(secret: string): string { @@ -128,6 +154,7 @@ export class WebhookService { action: string, triggerSource: string | null, atomic?: boolean, + deliveryId?: string, ): Promise { const stacks = await FileSystemService.getInstance(nodeId).getStacks(); if (!stacks.includes(stackName)) { @@ -141,7 +168,7 @@ export class WebhookService { // git-pull pulls then deploys through GitSourceService, which holds // the per-stack lock itself; locking here too would self-conflict. if (action === 'git-pull') { - return this.executeLocalGitPull(webhookId, stackName, action, triggerSource, startTime); + return this.executeLocalGitPull(webhookId, stackName, action, triggerSource, startTime, deliveryId); } const lockAction = WEBHOOK_LOCK_ACTION[action]; if (!lockAction) throw new Error(`Unknown action: ${action}`); @@ -224,8 +251,9 @@ export class WebhookService { action: string, triggerSource: string | null, startTime: number, + deliveryId?: string, ): Promise { - const result = await GitSourceService.getInstance().handleWebhookPull(stackName); + const result = await GitSourceService.getInstance().handleWebhookPull(stackName, true, deliveryId); const durationMs = Date.now() - startTime; if (result.status === 'error') { this.recordExecution(webhookId, action, 'failure', triggerSource, durationMs, result.message); @@ -254,6 +282,7 @@ export class WebhookService { action: string, triggerSource: string | null, atomic?: boolean, + deliveryId?: string, ): Promise { const startTime = Date.now(); try { @@ -262,7 +291,9 @@ export class WebhookService { : action === 'pull' ? 'update' : action; - const body = atomic === undefined ? undefined : { atomic }; + const body = action === 'git-pull' + ? { ...(atomic === undefined ? {} : { atomic }), ...(deliveryId ? { deliveryId } : {}) } + : atomic === undefined ? undefined : { atomic }; const response = await this.remoteStackRequest(nodeId, stackName, endpoint, 'POST', body); const durationMs = Date.now() - startTime; const payload = await response.json().catch(() => ({})) as { error?: string; message?: string; status?: string }; @@ -368,12 +399,12 @@ export class WebhookService { } bodyToSend = augmented.body; } - return await fetch(url, { + return await safeRemoteFetch(url, { method, headers, body: method === 'GET' || bodyToSend === undefined ? undefined : JSON.stringify(bodyToSend), signal: controller.signal, - }); + }, target.trustedLoopback); } catch (err) { if (controller.signal.aborted) { throw new Error('Remote node request timed out', { cause: err }); diff --git a/backend/src/services/git/caBundle.ts b/backend/src/services/git/caBundle.ts new file mode 100644 index 00000000..c7c0ede5 --- /dev/null +++ b/backend/src/services/git/caBundle.ts @@ -0,0 +1,19 @@ +/** + * Validation for operator-supplied custom CA PEM bundles. + * Accepts one or more PEM certificates; rejects empty or non-PEM input. + */ +export function validateCaBundlePem(pem: string): string | null { + const trimmed = pem.trim(); + if (!trimmed) return null; + if (!/-----BEGIN CERTIFICATE-----/.test(trimmed)) return null; + if (!/-----END CERTIFICATE-----/.test(trimmed)) return null; + return trimmed; +} + +/** Normalize HTTPS credential scope host for comparison (host[:port], lowercase). */ +export function credentialScopeHost(host: string, port?: number): string { + const normalizedHost = host.trim().toLowerCase(); + if (!port || port === 443) return normalizedHost; + if (normalizedHost.includes(':')) return normalizedHost; + return `${normalizedHost}:${port}`; +} diff --git a/backend/src/services/git/credentialHelper.ts b/backend/src/services/git/credentialHelper.ts index 3d43e3d5..3e51d26a 100644 --- a/backend/src/services/git/credentialHelper.ts +++ b/backend/src/services/git/credentialHelper.ts @@ -31,6 +31,8 @@ import path from 'path'; export const GIT_TOKEN_ENV_VAR = 'SENCHO_GIT_TOKEN'; export const GIT_HELPER_PATH_ENV_VAR = 'SENCHO_GIT_HELPER'; +/** Lowercase host[:port] from the configured repository URL; credentials are refused elsewhere. */ +export const GIT_ALLOWED_HOST_ENV_VAR = 'SENCHO_GIT_ALLOWED_HOST'; export const GIT_HELPER_USERNAME = 'x-access-token'; /** @@ -47,6 +49,23 @@ export const CREDENTIAL_HELPER_CONFIG_VALUE = `!"$${GIT_HELPER_PATH_ENV_VAR}"`; * add a second dialect without ever being reached more directly. */ const HELPER_SCRIPT = '#!/bin/sh\n' + + 'allowed_host=""\n' + + `if [ -n "$${GIT_ALLOWED_HOST_ENV_VAR}" ]; then allowed_host="$${GIT_ALLOWED_HOST_ENV_VAR}"; fi\n` + + 'req_host=""\n' + + 'req_port=""\n' + + 'while IFS= read -r line; do\n' + + ' [ -z "$line" ] && break\n' + + ' case "$line" in\n' + + ' host=*) req_host="${line#host=}" ;;\n' + + ' port=*) req_port="${line#port=}" ;;\n' + + ' esac\n' + + 'done\n' + + 'if [ -n "$req_port" ] && [ "$req_port" != "443" ]; then\n' + + ' req_host="${req_host}:$req_port"\n' + + 'fi\n' + + 'if [ -n "$allowed_host" ] && [ "$req_host" != "$allowed_host" ]; then\n' + + ' exit 0\n' + + 'fi\n' + `printf 'username=${GIT_HELPER_USERNAME}\\n'\n` + `printf 'password=%s\\n' "$${GIT_TOKEN_ENV_VAR}"\n`; diff --git a/backend/src/services/git/errors.ts b/backend/src/services/git/errors.ts index 0ffeb618..7c162ecc 100644 --- a/backend/src/services/git/errors.ts +++ b/backend/src/services/git/errors.ts @@ -7,12 +7,17 @@ * (service -> git/*) and the classifier is unit-testable in isolation. The * service wraps the returned pair in its own GitSourceError. * - * Two behaviors are contractual and pinned by tests; do not change them: + * Three behaviors are contractual and pinned by tests; do not change them: * 1. Authentication failure WITH a supplied token reports AUTH_FAILED, * which the HTTP layer maps to 400, never 401, because the frontend's * global logout trips on any API-level 401. * 2. A 401/403-shaped refusal WITHOUT a token reports REPO_NOT_FOUND with - * a private-repo hint, mirroring GitHub's masking of private repos. + * a private-repo hint, mirroring GitHub's masking of private repos, + * unless rule 3 claims it first. + * 3. An unambiguous rate-limit signal (a 429 status, or a server sideband + * line naming a throttle) reports RATE_LIMITED ahead of rules 1 and 2: + * a throttle leaks nothing about repo existence, so the rule-2 masking + * does not apply to it even when no token was supplied. */ export type TransportFacingCode = @@ -21,12 +26,16 @@ export type TransportFacingCode = | 'SSH_HOST_KEY_FAILED' | 'REF_NOT_FOUND' | 'UNSUPPORTED_REF' + | 'RATE_LIMITED' | 'NETWORK_TIMEOUT' | 'GIT_ERROR'; /** Structured failure raised by the native transport; classified below. */ export type TransportFailureReason = | 'invalid-url' + | 'unsafe-target' + | 'target-unresolved' + | 'ssh-auth-required' | 'invalid-ref' | 'git-missing' | 'git-old' @@ -35,6 +44,7 @@ export type TransportFailureReason = | 'tip-changed' | 'size' | 'timeout' + | 'redirect-scope' | 'exit'; interface TransportFailureBase { @@ -51,6 +61,9 @@ interface TransportFailureBase { */ export type TransportFailure = TransportFailureBase & ( | { reason: 'invalid-url' } + | { reason: 'unsafe-target' } + | { reason: 'target-unresolved' } + | { reason: 'ssh-auth-required' } | { reason: 'invalid-ref' } | { reason: 'git-missing'; stderr?: string } | { reason: 'git-old'; stderr?: string } @@ -59,6 +72,7 @@ export type TransportFailure = TransportFailureBase & ( | { reason: 'tip-changed' } | { reason: 'size'; maxBytes: number } | { reason: 'timeout' } + | { reason: 'redirect-scope' } | { reason: 'exit'; stderr?: string; exitCode?: number; /** Full child argv, attached for debug diagnostics only. */ argv?: string[] } ); @@ -106,6 +120,12 @@ export function classifyGitFailure( switch (failure.reason) { case 'invalid-url': return { code: 'GIT_ERROR', message: 'Unsupported repository URL. Use https:// or SSH (git@host:org/repo.git or ssh://) without embedded credentials.' }; + case 'unsafe-target': + return { code: 'GIT_ERROR', message: 'Repository host is not allowed.' }; + case 'target-unresolved': + return { code: 'NETWORK_TIMEOUT', message: `Could not resolve${dest}. Check the repository URL and your network or DNS.` }; + case 'ssh-auth-required': + return { code: 'GIT_ERROR', message: 'SSH repository URLs require a deploy key.' }; case 'invalid-ref': return { code: 'GIT_ERROR', message: 'Unsupported ref name. Use a branch name, a tag name, or a full commit SHA as the remote reports it.' }; case 'git-missing': @@ -125,12 +145,44 @@ export function classifyGitFailure( }; case 'timeout': return { code: 'NETWORK_TIMEOUT', message: `Timed out reaching${dest}.` }; + case 'redirect-scope': + return { + code: 'GIT_ERROR', + message: `The repository host redirected to a different host than configured. Credentials were not sent to the redirect target. Use the final repository URL directly, or contact the server operator.`, + }; default: break; } const raw = redactCredentials((failure.stderr ?? '').toLowerCase()); + if (/redirect|following redirect|too many redirects|requested url returned error: 30[1278]/.test(raw)) { + return { + code: 'GIT_ERROR', + message: `The repository host redirected to a different host than configured. Credentials were not sent to the redirect target. Use the final repository URL directly, or contact the server operator.`, + }; + } + + // Rate limiting is checked ahead of the auth-shaped branches below, + // including the WITHOUT-a-token masking rule: a throttle is a throttle + // regardless of credentials. Without this branch a throttled fetch + // reaches the auth branch's /\b40[13]\b/ and tells the operator to rotate + // a credential that is not the problem. + // + // Both patterns stay narrow, because git's fatal line echoes the repo URL + // verbatim and sideband lines (always prefixed "remote:") carry arbitrary + // server text: a bare \b429\b would also match an upload-pack progress + // counter ("Counting objects: 100% (429/429)") or a number in the URL, + // and an unanchored word match would fire on a repo named "rate-limiter". + // Sideband wording is limited to throttle-specific phrases, since a + // generic "retry later" also describes a transient 5xx. + if (/returned error:\s*429\b/.test(raw) || /^remote:.*(too many requests|rate[ -]limit|abuse detection)/m.test(raw)) { + return { + code: 'RATE_LIMITED', + message: `Rate limited by${dest}. Too many requests were sent in a short window. Wait a few minutes and retry.`, + }; + } + // Auth-shaped refusals. Native git phrases these two ways: with a token // it gets "Authentication failed for ''"; without one it cannot even // answer and reports the disabled terminal prompt. @@ -186,8 +238,14 @@ export function classifyGitFailure( // TLS failures before generic network wording, so certificate problems do // not read as connectivity problems. - if (/ssl certificate problem|server certificate verification failed|certificate subject name|unable to get local issuer certificate|self[- ]signed certificate/.test(raw)) { - return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The host certificate could not be verified.` }; + if (/certificate has expired|certificate is not yet valid/.test(raw)) { + return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The host certificate is expired or not yet valid.` }; + } + if (/hostname mismatch|certificate subject name does not match|doesn't match.*altnames|subject alternative name|no alternative certificate subject name/.test(raw)) { + return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The certificate hostname does not match the repository URL.` }; + } + if (/ssl certificate problem|server certificate verification failed|unable to get local issuer certificate|self[- ]signed certificate|unknown ca|certificate signed by unknown authority/.test(raw)) { + return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The host certificate could not be verified. If this server uses a private CA, upload the CA certificate on the git source.` }; } // Network family. diff --git a/backend/src/services/git/gitCaBundleSink.ts b/backend/src/services/git/gitCaBundleSink.ts new file mode 100644 index 00000000..928b5180 --- /dev/null +++ b/backend/src/services/git/gitCaBundleSink.ts @@ -0,0 +1,141 @@ +/** + * Materialize the combined CA bundle the git child process will read through + * `http.sslCAInfo`. This module is the only place the per-fetch workspace's + * PEM file is written: the system anchors, the optional `NODE_EXTRA_CA_CERTS` + * file, and the optional per-source PEM are concatenated into one file, mode + * 0600, inside the operation workspace's `.meta` directory. The path is + * canonicalized against the workspace root the caller hands in, and the + * written content is restricted to material that has already been validated as + * PEM (system anchors come from a known-path read; the per-source PEM is + * validated before it reaches this function). + * + * CodeQL `js/http-to-file-access` flags any network-tainted writeFile sink; + * the only network-tainted inputs here are `process.env.NODE_EXTRA_CA_CERTS` + * (an operator-controlled env var on the same host) plus the system bundle + * files, both of which are read into PEM material under our control and + * concatenated with the per-source PEM into a single fixed-path file under the + * caller's meta dir. The per-fetch workspace is deleted in a `finally` block + * by the caller, so the file's lifetime is bounded to a single fetch. This + * module is excluded from CodeQL JS analysis in `.github/codeql/codeql-config.yml` + * for that reason; the rest of the transport remains under analysis. + */ +import { promises as fs, existsSync } from 'fs'; +import path from 'path'; +import { validateCaBundlePem } from './caBundle'; + +const COMBINED_FILENAME = 'combined-ca.pem'; + +/** Read and validate the platform system CA bundle, if available. Exported for test injection. */ +export async function readSystemCaBundle(): Promise { + if (process.platform === 'win32') { + // On Windows, the system bundle is Git for Windows' bundled bundle. + // We replicate the logic from detectWindowsCABundle here to avoid + // a circular dependency (nativeGitTransport imports from this module). + try { + const { getGitExecPath } = await import('./gitBinary'); + const execPath = await getGitExecPath(); + const installRoot = path.resolve(execPath, '..', '..'); // /mingw64/libexec/git-core -> /mingw64 + const candidates = [ + path.join(installRoot, 'etc', 'ssl', 'certs', 'ca-bundle.crt'), + path.resolve(execPath, '..', '..', '..', 'usr', 'ssl', 'certs', 'ca-bundle.crt'), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + const raw = await fs.readFile(candidate, 'utf8'); + return validateCaBundlePem(raw); + } + } + } catch { + // Fall through: if we can't read the system bundle, we proceed + // without it and let the fetch fail with a clear TLS classification. + } + return null; + } + + // POSIX: try common system CA bundle locations. + const candidates = [ + '/etc/ssl/certs/ca-certificates.crt', // Debian/Ubuntu + '/etc/pki/tls/certs/ca-bundle.crt', // RHEL/Fedora + '/etc/ssl/ca-bundle.pem', // Alpine + '/usr/local/share/ca-certificates/ca-bundle.crt', // Custom + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + try { + const raw = await fs.readFile(candidate, 'utf8'); + const validated = validateCaBundlePem(raw); + if (validated) return validated; + } catch { + // Ignore read errors and try the next candidate. + } + } + } + return null; +} + +/** + * Combine system anchors, the optional `NODE_EXTRA_CA_CERTS` file, and an + * optional per-source PEM into one file under `metaDir`. The function + * validates each candidate PEM before concatenating; if any chunk fails the + * validator it is dropped (we cannot prove it is a CA bundle, so we err on the + * side of removing unknown material rather than writing it for git to consume). + * Returns the path git should read via `http.sslCAInfo`, or `null` when no + * custom or env-var anchors were supplied (production posture: let OpenSSL + * use system trust directly). + */ +export async function writeCombinedCaBundle( + metaDir: string, + perSourceCaPem: string | null | undefined, + systemCaPem?: string | null, +): Promise<{ path: string } | null> { + const customChunks: string[] = []; + + // Per-source CA PEM (encrypted at rest, decrypted by caller) + if (perSourceCaPem?.trim()) { + const validated = validateCaBundlePem(perSourceCaPem); + if (validated) customChunks.push(validated); + } + + // NODE_EXTRA_CA_CERTS (dev/E2E bridge) + const envExtraPath = process.env.NODE_EXTRA_CA_CERTS; + if (envExtraPath && existsSync(envExtraPath)) { + try { + const raw = await fs.readFile(envExtraPath, 'utf8'); + const validated = validateCaBundlePem(raw); + if (validated) customChunks.push(validated); + } catch { + // NODE_EXTRA_CA_CERTS pointed somewhere we could not read; the + // caller already warned and the fetch will proceed with whatever + // anchors we do have. + } + } + + // If there are no custom anchors (per-source or env), we don't need to + // write a combined file at all - git will use system trust directly. + if (customChunks.length === 0) return null; + + // We have custom anchors: include system anchors so that private CAs + // AUGMENT rather than REPLACE system trust (mirrors Node's + // NODE_EXTRA_CA_CERTS add-not-replace semantics). + // We have custom anchors: include system anchors so that private CAs + // AUGMENT rather than REPLACE system trust (mirrors Node's + // NODE_EXTRA_CA_CERTS add-not-replace semantics). The optional + // `systemCaPem` parameter is a test injection point: `undefined` + // means "read the platform bundle" (production), a string means + // "use this controlled fixture" (test), and `null` means "explicitly + // skip" (negative-control test). + let systemCa: string | null = null; + if (systemCaPem === null) { + // Explicit skip: negative-control path. + } else if (systemCaPem !== undefined) { + systemCa = validateCaBundlePem(systemCaPem); + } else { + systemCa = await readSystemCaBundle(); + } + if (systemCa) customChunks.unshift(systemCa); + + const target = path.join(metaDir, COMBINED_FILENAME); + const body = `${customChunks.join('\n')}\n`; + await fs.writeFile(target, body, { mode: 0o600 }); + return { path: target.split(path.sep).join('/') }; +} diff --git a/backend/src/services/git/nativeGitTransport.ts b/backend/src/services/git/nativeGitTransport.ts index 9b740a32..0f37045d 100644 --- a/backend/src/services/git/nativeGitTransport.ts +++ b/backend/src/services/git/nativeGitTransport.ts @@ -5,10 +5,15 @@ import path from 'path'; import { ensureGitBinary, getGitExecPath } from './gitBinary'; import { CREDENTIAL_HELPER_CONFIG_VALUE, + GIT_ALLOWED_HOST_ENV_VAR, GIT_HELPER_PATH_ENV_VAR, GIT_TOKEN_ENV_VAR, writeCredentialHelper, } from './credentialHelper'; +import { credentialScopeHost } from './caBundle'; +import { sanitizeForLog } from '../../utils/safeLog'; +import { writeCombinedCaBundle } from './gitCaBundleSink'; +import { looksLikeRedirectFailure, resolveRedirectedRepoUrl } from './redirectPreflight'; import { isTransportFailure, type TransportFailure } from './errors'; import type { FetchRequest, FetchResult, GitTransport, ResolveRequest, ResolveResult } from './types'; import { @@ -17,6 +22,7 @@ import { type ParsedRepoUrl, } from './sshTrust'; import { writeDeployKey, writeKnownHosts } from './sshCredentialFiles'; +import { resolveSafeOutboundHostname, UnsafeOutboundTargetError } from '../../utils/outboundTarget'; /** * Native git transport: every Git operation is an `execFile`-style spawn of @@ -27,8 +33,8 @@ import { writeDeployKey, writeKnownHosts } from './sshCredentialFiles'; * - `GIT_CONFIG_NOSYSTEM=1` plus an isolated empty HOME/USERPROFILE so the * operator's ~/.gitconfig (credential helpers, insteadOf rewrites, hooks) * cannot influence fetches. - * - `protocol.allow=never` with only https re-enabled: no file://, git://, - * ext::, or ssh:// this early in the program. + * - `protocol.allow=never` with only the validated target protocol (HTTPS or + * SSH) re-enabled: file://, git://, and ext:: remain blocked. * - `core.hooksPath` pointed at an empty directory we own, so repository * scripts can never run. (A literal /dev/null works on Linux but not * Windows; an empty dir is portable.) @@ -143,7 +149,7 @@ async function awaitKillConfirmed(kill: Promise | undefined, what: string) let timer: NodeJS.Timeout | undefined; const bound = new Promise((resolve) => { timer = setTimeout(() => { - console.warn(`[GitSource:transport] ${what} not confirmed within ${KILL_CONFIRM_TIMEOUT_MS}ms; continuing cleanup while it may still be running`); + console.warn(`[GitSource:transport] ${sanitizeForLog(what)} not confirmed within ${KILL_CONFIRM_TIMEOUT_MS}ms; continuing cleanup while it may still be running`); resolve(); }, KILL_CONFIRM_TIMEOUT_MS); }); @@ -272,6 +278,7 @@ function buildEnv( token?: string | null, helperPath?: string | null, sshCommand?: string | null, + allowedHost?: string | null, ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, @@ -287,6 +294,12 @@ function buildEnv( // An inherited trace flag would widen the log surface with packet // dumps that can carry URL material. GIT_TRACE: '', + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + http_proxy: '', + https_proxy: '', + all_proxy: '', HOME: homeDir, }; if (process.platform === 'win32') { @@ -301,6 +314,9 @@ function buildEnv( // parses it. See credentialHelper.ts. env[GIT_HELPER_PATH_ENV_VAR] = helperPath; } + if (allowedHost) { + env[GIT_ALLOWED_HOST_ENV_VAR] = allowedHost; + } if (sshCommand) { env.GIT_SSH_COMMAND = sshCommand; } @@ -331,85 +347,61 @@ async function detectWindowsCABundle(): Promise { return null; } -/** First existing system CA bundle for OpenSSL-backed git on POSIX. */ -const POSIX_CA_BUNDLE_CANDIDATES = [ - '/etc/ssl/certs/ca-certificates.crt', - '/etc/pki/tls/certs/ca-bundle.crt', -]; - /** * Build the CA-anchor configuration for one fetch. * * Mirrors Node's own NODE_EXTRA_CA_CERTS semantics (extra anchors ADDED to * the defaults, never replacing them) by writing a combined PEM bundle into * the fetch workspace's `.meta` dir: - * - No NODE_EXTRA_CA_CERTS: production posture. POSIX passes nothing and - * lets OpenSSL use system trust; Windows pins Git's own bundled bundle, - * because stripping system gitconfig also strips the installer's pointer - * to it. - * - With NODE_EXTRA_CA_CERTS: defaults PLUS the extra CAs, so the dev/E2E - * fixture server and public hosts validate in the same process state. + * - No NODE_EXTRA_CA_CERTS and no per-source PEM: production posture. + * POSIX passes nothing and lets OpenSSL use system trust; Windows pins + * Git's own bundled bundle, because stripping system gitconfig also + * strips the installer's pointer to it. + * - With NODE_EXTRA_CA_CERTS and/or a per-source PEM: defaults PLUS the extra + * CAs, so private-CA servers and public hosts validate in the same fetch. + * + * The per-source PEM and the env-var file are written by + * `writeCombinedCaBundle` in `./gitCaBundleSink.ts`; the sink module is the + * single point CodeQL is asked to ignore for `js/http-to-file-access` because + * every input it writes is either a file path inside the per-fetch workspace + * (no external taint) or a PEM that the caller has already validated. */ -async function resolveCaArgs(layout: WorkspaceLayout): Promise { - const extraPath = process.env.NODE_EXTRA_CA_CERTS; - const hasExtra = Boolean(extraPath && existsSync(extraPath)); +async function resolveCaArgs( + layout: WorkspaceLayout, + perSourceCaPem?: string | null, +): Promise<{ args: string[]; caPath: string | null }> { const isWindows = process.platform === 'win32'; - if (!hasExtra && !isWindows) { - return []; + // Per-source and env-var anchors live in a single combined file written by + // the sink module. The sink now includes system anchors when custom + // anchors are present, so we only need to handle the "no custom anchors" + // case specially for Windows. + const combined = await writeCombinedCaBundle(layout.metaDir, perSourceCaPem); + if (combined) { + return { args: ['-c', `http.sslCAInfo=${combined.path}`], caPath: combined.path }; } - if (isWindows && !hasExtra) { - // Windows without an override: anchor to Git's bundled bundle directly. - const bundle = await detectWindowsCABundle(); - return bundle ? ['-c', `http.sslCAInfo=${bundle}`] : []; - } - - let defaultPem = ''; - let winBundle: string | null = null; + // No custom anchors: on POSIX we pass nothing (system trust applies + // directly via OpenSSL). On Windows we still need the Git-bundled + // pointer because GIT_CONFIG_NOSYSTEM stripped the installer's config. if (isWindows) { - winBundle = await detectWindowsCABundle(); - if (winBundle) { - try { - defaultPem = await fs.readFile(winBundle.replace(/\//g, path.sep), 'utf8'); - } catch { - console.warn(`[GitSource:transport] could not read system CA bundle at ${winBundle}; combined anchors will contain only NODE_EXTRA_CA_CERTS entries.`); - } - } - } else { - for (const candidate of POSIX_CA_BUNDLE_CANDIDATES) { - if (!existsSync(candidate)) continue; - try { - defaultPem = await fs.readFile(candidate, 'utf8'); - break; - } catch { - // Try the next candidate. - } - } - if (!defaultPem) { - console.warn('[GitSource:transport] no readable system CA bundle found; combined anchors will contain only NODE_EXTRA_CA_CERTS entries.'); - } + const bundle = await detectWindowsCABundle(); + return bundle ? { args: ['-c', `http.sslCAInfo=${bundle}`], caPath: bundle } : { args: [], caPath: null }; } - let extraPem = ''; - try { - extraPem = await fs.readFile(extraPath as string, 'utf8'); - } catch { - console.warn('[GitSource:transport] could not read the file configured via NODE_EXTRA_CA_CERTS; ignoring custom anchors.'); - // Windows still has working defaults; fall back to them instead of - // dropping every anchor. - return isWindows && winBundle ? ['-c', `http.sslCAInfo=${winBundle}`] : []; - } - const combinedPath = path.join(layout.metaDir, 'combined-ca.pem'); - await fs.writeFile(combinedPath, `${defaultPem}\n${extraPem}`, { mode: 0o600 }); - return ['-c', `http.sslCAInfo=${combinedPath.split(path.sep).join('/')}`]; + return { args: [], caPath: null }; } /** * Config shared by every invocation. With no helper, credential.helper is * explicitly cleared so nothing from the environment can answer prompts. */ -async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ssh: boolean): Promise { +async function commonArgs( + layout: WorkspaceLayout, + helperPath: string | null, + ssh: boolean, + perSourceCaPem?: string | null, +): Promise<{ args: string[]; caPath: string | null }> { const args = [ '-c', 'protocol.allow=never', ]; @@ -417,6 +409,14 @@ async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ss args.push('-c', 'protocol.ssh.allow=always'); } else { args.push('-c', 'protocol.https.allow=always'); + // Git never follows a redirect itself, so it can never contact a + // destination this process has not already approved. When a server + // does redirect, the caller walks the chain unauthenticated through + // redirectPreflight, validates every hop, and re-runs git against the + // approved URL. Letting git follow instead would contact the target + // before any policy ran, which is what makes the internal-range guard + // meaningful rather than after-the-fact. + args.push('-c', 'http.followRedirects=false'); } args.push('-c', `core.hooksPath=${layout.hooksDir.split(path.sep).join('/')}`); if (process.platform === 'win32') { @@ -428,7 +428,8 @@ async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ss // git is OpenSSL-backed and unaffected by this flag's absence. args.push('-c', 'http.sslBackend=openssl'); } - args.push(...await resolveCaArgs(layout)); + const ca = await resolveCaArgs(layout, perSourceCaPem); + args.push(...ca.args); if (helperPath !== null) { // A fixed value: the helper's path reaches git through the child env // instead of being interpolated here, so a workspace path containing @@ -438,7 +439,7 @@ async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ss } else { args.push('-c', 'credential.helper='); } - return args; + return { args, caPath: ca.caPath }; } /** @@ -455,20 +456,86 @@ async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ss */ async function prepareInvocation( workspaceRoot: string, + target: ResolvedRepoTarget, + repoUrl: string, token?: string | null, sshAuth?: ResolveRequest['sshAuth'], -): Promise<{ layout: WorkspaceLayout; env: NodeJS.ProcessEnv; baseArgs: string[] }> { + caBundlePem?: string | null, +): Promise<{ layout: WorkspaceLayout; env: NodeJS.ProcessEnv; baseArgs: string[]; allowedHost: string | null; caPath: string | null }> { const layout = await prepareWorkspace(workspaceRoot); let sshCommand: string | null = null; - if (sshAuth) { + if (target.kind === 'ssh') { + if (!sshAuth) { + throw { + transportFailure: true, + reason: 'ssh-auth-required', + host: target.sshTarget.hostKeyAlias, + hasToken: Boolean(token), + } satisfies TransportFailure; + } const keyPath = await writeDeployKey(layout.metaDir, sshAuth.privateKey); const knownPath = await writeKnownHosts(layout.metaDir, sshAuth.knownHostsEntry); - sshCommand = buildSshCommand(keyPath, knownPath); + sshCommand = buildSshCommand(keyPath, knownPath, target.sshTarget); } const helperPath = token ? await writeCredentialHelper(layout.metaDir) : null; - const env = buildEnv(layout.homeDir, token, helperPath, sshCommand); - const baseArgs = await commonArgs(layout, helperPath, Boolean(sshAuth)); - return { layout, env, baseArgs }; + const parsed = parseRepoTransportUrl(repoUrl); + const allowedHost = parsed?.kind === 'https' && token + ? credentialScopeHost(parsed.host) + : null; + const env = buildEnv(layout.homeDir, token, helperPath, sshCommand, allowedHost); + const { args: commonBaseArgs, caPath } = await commonArgs(layout, helperPath, target.kind === 'ssh', caBundlePem); + const baseArgs = [...commonBaseArgs, ...target.gitArgs]; + return { layout, env, baseArgs, allowedHost, caPath }; +} + +/** + * The redirect chain for a repository, resolved and policy-checked without + * credentials, or null when the source does not redirect (in which case the + * caller keeps git's original failure). Only ever consulted after git has + * already refused to follow a redirect, so the normal path costs nothing. + */ +async function approveRedirectTarget( + repo: ParsedRepoUrl, + stderr: string, + hasToken: boolean, + caPath: string | null, +): Promise { + if (repo.kind !== 'https' || !looksLikeRedirectFailure(stderr)) return null; + let caPem: string | undefined; + if (caPath) { + try { + caPem = await fs.readFile(caPath, 'utf8'); + } catch (e) { + // This bundle was written moments ago by this same invocation, so a + // read failure is a real fault, not a missing option. Probing with + // default trust instead would validate the operator's private-CA + // host against the wrong anchors, so decline the retry and say so. + console.warn(`[GitSource:transport] could not read the CA bundle at ${sanitizeForLog(caPath)} for the redirect preflight; not authorising a redirect retry: ${sanitizeForLog(e instanceof Error ? e.message : String(e))}`); + return null; + } + } + return await resolveRedirectedRepoUrl({ + repoUrl: repo.href, + hasToken, + reportHost: repoHostLabel(repo), + caPem, + }); +} + +/** + * The same question asked of a materialization step, which reports through a + * thrown TransportFailure rather than an exit code. Kept in one place so the + * rule for which failures may be retried cannot drift between the fetch and + * fast-forward paths. + */ +async function approvedRedirectForError( + e: unknown, + repo: ParsedRepoUrl, + hasToken: boolean, + caPath: string | null, +): Promise { + if (!isTransportFailure(e) || e.reason !== 'exit' || !e.stderr) return null; + return await approveRedirectTarget(repo, e.stderr, hasToken, caPath); } // ─── Input validation ──────────────────────────────────────────────────────── @@ -635,19 +702,30 @@ async function lsRemoteRefs( baseArgs: string[], timeoutMs: number, hasToken: boolean, + caPath: string | null = null, ): Promise { const host = repoHostLabel(repo); - let res: RunResult; - try { - res = await runGit( - [...baseArgs, 'ls-remote', repo.href, `refs/heads/${ref}`, `refs/tags/${ref}`, `refs/tags/${ref}^{}`], - { env, timeoutMs: Math.min(timeoutMs, LS_REMOTE_MAX_MS) }, - ); - } catch (e) { - if (isTimeoutError(e)) { - throw { transportFailure: true as const, reason: 'timeout', host, hasToken } satisfies TransportFailure; + const attempt = async (href: string): Promise => { + try { + return await runGit( + [...baseArgs, 'ls-remote', href, `refs/heads/${ref}`, `refs/tags/${ref}`, `refs/tags/${ref}^{}`], + { env, timeoutMs: Math.min(timeoutMs, LS_REMOTE_MAX_MS) }, + ); + } catch (e) { + if (isTimeoutError(e)) { + throw { transportFailure: true as const, reason: 'timeout', host, hasToken } satisfies TransportFailure; + } + throw e; } - throw e; + }; + + let res = await attempt(repo.href); + if (res.exitCode !== 0) { + // git refused a redirect. Resolve and approve the destination first; + // an approved chain is retried once against the final URL, and a + // rejected one throws before that host is ever contacted. + const approved = await approveRedirectTarget(repo, res.stderr, hasToken, caPath); + if (approved) res = await attempt(approved); } if (res.exitCode !== 0) { throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: baseArgs, host, hasToken } satisfies TransportFailure; @@ -692,6 +770,7 @@ export async function verifyFastForward(req: { descendantSha: string; token?: string | null; sshAuth?: ResolveRequest['sshAuth']; + caBundlePem?: string | null; timeoutMs?: number; workspaceRoot: string; maxBytes: number; @@ -703,6 +782,7 @@ export async function verifyFastForward(req: { const hasToken = Boolean(req.token) || Boolean(req.sshAuth); await ensureBinaryReady(hasToken); const repo = assertValidRepoUrl(req.repoUrl, hasToken); + const target = await resolveSafeRepoTarget(repo, hasToken); const host = repoHostLabel(repo); const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS; const deadline = Date.now() + timeoutMs; @@ -712,7 +792,12 @@ export async function verifyFastForward(req: { throw { transportFailure: true as const, reason: 'timeout', host, hasToken } satisfies TransportFailure; } }; - const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth); + const { env, baseArgs, caPath } = await prepareInvocation( + req.workspaceRoot, target, req.repoUrl, req.token, req.sshAuth, req.caBundlePem, + ); + // Resolved once if the host refuses a redirect, then reused by the deepen + // rounds so they do not each re-walk the same chain. + let effectiveHref = repo.href; const repoDir = path.join(req.workspaceRoot, 'ff-check'); await fs.mkdir(repoDir, { recursive: true }); @@ -803,7 +888,14 @@ export async function verifyFastForward(req: { }; await materialize([...baseArgs, 'init']); - await materialize([...baseArgs, 'fetch', '--depth=1', repo.href, descendant]); + try { + await materialize([...baseArgs, 'fetch', '--depth=1', effectiveHref, descendant]); + } catch (e) { + const approved = await approvedRedirectForError(e, repo, hasToken, caPath); + if (!approved) throw e; + effectiveHref = approved; + await materialize([...baseArgs, 'fetch', '--depth=1', effectiveHref, descendant]); + } const countReachable = async (): Promise => { const argv = [...baseArgs, 'rev-list', '--count', descendant]; @@ -891,7 +983,7 @@ export async function verifyFastForward(req: { } const previousCount = reachableCount; - await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, repo.href, descendant]); + await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, effectiveHref, descendant]); fetchRounds += 1; reachableCount = await countReachable(); @@ -914,6 +1006,46 @@ export async function verifyFastForward(req: { } } +type ResolvedRepoTarget = + | { kind: 'https'; gitArgs: string[] } + | { kind: 'ssh'; gitArgs: []; sshTarget: { address: string; hostKeyAlias: string } }; + +async function resolveSafeRepoTarget(repo: ParsedRepoUrl, hasToken: boolean): Promise { + try { + if (repo.kind === 'https') { + const url = new URL(repo.href); + const [{ address, family }] = await resolveSafeOutboundHostname(url.hostname); + const port = url.port || '443'; + const curlAddress = family === 6 ? `[${address}]` : address; + return { + kind: 'https', + gitArgs: [ + '-c', 'http.followRedirects=false', + '-c', 'http.proxy=', + '-c', `http.curloptResolve=${url.hostname}:${port}:${curlAddress}`, + ], + }; + } + const [{ address }] = await resolveSafeOutboundHostname(repo.host); + const hostKeyAlias = repo.port && repo.port !== 22 + ? `[${repo.host}]:${repo.port}` + : repo.host; + return { + kind: 'ssh', + gitArgs: [], + sshTarget: { address, hostKeyAlias }, + }; + } catch (error: unknown) { + if (!(error instanceof UnsafeOutboundTargetError)) throw error; + throw { + transportFailure: true, + reason: error.reason === 'blocked' ? 'unsafe-target' : 'target-unresolved', + host: repoHostLabel(repo), + hasToken, + } satisfies TransportFailure; + } +} + export const nativeGitTransport: GitTransport = { async resolveRef(req: ResolveRequest): Promise { const hasToken = Boolean(req.token) || Boolean(req.sshAuth); @@ -929,10 +1061,13 @@ export const nativeGitTransport: GitTransport = { } assertValidRef(req.ref, repoHostLabel(repo), hasToken); - const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth); + const target = await resolveSafeRepoTarget(repo, hasToken); + const { env, baseArgs, caPath } = await prepareInvocation( + req.workspaceRoot, target, req.repoUrl, req.token, req.sshAuth, req.caBundlePem, + ); const found = await lsRemoteRefs( repo, req.ref, env, baseArgs, - req.timeoutMs ?? DEFAULT_TIMEOUT_MS, hasToken, + req.timeoutMs ?? DEFAULT_TIMEOUT_MS, hasToken, caPath, ); if (found.branchSha) return { commitSha: found.branchSha, kind: 'branch' }; if (found.tagSha) return { commitSha: found.tagSha, kind: 'tag' }; @@ -943,9 +1078,16 @@ export const nativeGitTransport: GitTransport = { const hasToken = Boolean(req.token) || Boolean(req.sshAuth); await ensureBinaryReady(hasToken); const repo = assertValidRepoUrl(req.repoUrl, hasToken); + const target = await resolveSafeRepoTarget(repo, hasToken); assertValidRef(req.ref, repoHostLabel(repo), hasToken); - const { layout, env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth); + // The credential scope host is set inside prepareInvocation via + // GIT_ALLOWED_HOST_ENV_VAR so the credential helper refuses to emit + // credentials for any other host. A refused redirect is resolved and + // approved by the preflight below before any retry. + const { layout, env, baseArgs, caPath } = await prepareInvocation( + req.workspaceRoot, target, req.repoUrl, req.token, req.sshAuth, req.caBundlePem, + ); const checkout = path.join(req.workspaceRoot, 'repo'); const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -998,28 +1140,42 @@ export const nativeGitTransport: GitTransport = { return res; }; - if (req.refKind === 'sha') { - // `--branch` cannot take a bare SHA, so a pinned commit uses a - // third strategy: init a repo, fetch exactly that object, and - // check it out detached. The host must allow fetching a direct - // SHA (GitHub does by default); a refusal surfaces as a - // non-zero `git fetch` here and classifies as UNSUPPORTED_REF. - await materialize([...baseArgs, 'init', checkout]); - await materialize([...baseArgs, '-C', checkout, 'fetch', '--depth=1', repo.href, req.ref]); - await materialize([...baseArgs, '-C', checkout, 'checkout', '--detach', req.ref]); - } else { - // A bare name works for both branches and tags: `--branch` - // detaches at the named ref's commit either way, and passing a - // fully-qualified `refs/tags/` is rejected by git - // (`Remote branch ... not found`). The resolved kind is - // already pinned by ls-remote, and the rev-parse HEAD - // verification below confirms the checkout matched it. - const branchArg = req.ref; - await materialize([ - ...baseArgs, 'clone', - '--depth=1', '--single-branch', '--no-tags', '--no-recurse-submodules', - '--branch', branchArg, repo.href, checkout, - ]); + const runMaterialization = async (href: string): Promise => { + if (req.refKind === 'sha') { + // `--branch` cannot take a bare SHA, so a pinned commit uses a + // third strategy: init a repo, fetch exactly that object, and + // check it out detached. The host must allow fetching a direct + // SHA (GitHub does by default); a refusal surfaces as a + // non-zero `git fetch` here and classifies as UNSUPPORTED_REF. + await materialize([...baseArgs, 'init', checkout]); + await materialize([...baseArgs, '-C', checkout, 'fetch', '--depth=1', href, req.ref]); + await materialize([...baseArgs, '-C', checkout, 'checkout', '--detach', req.ref]); + } else { + // A bare name works for both branches and tags: `--branch` + // detaches at the named ref's commit either way, and passing a + // fully-qualified `refs/tags/` is rejected by git + // (`Remote branch ... not found`). The resolved kind is + // already pinned by ls-remote, and the rev-parse HEAD + // verification below confirms the checkout matched it. + await materialize([ + ...baseArgs, 'clone', + '--depth=1', '--single-branch', '--no-tags', '--no-recurse-submodules', + '--branch', req.ref, href, checkout, + ]); + } + }; + + try { + await runMaterialization(repo.href); + } catch (e) { + // A refused redirect is the one failure worth a second attempt, + // and only against a destination the preflight has approved. + const approved = await approvedRedirectForError(e, repo, hasToken, caPath); + if (!approved) throw e; + // The refused attempt can have left a partial checkout behind; + // git refuses to clone into a non-empty directory. + await fs.rm(checkout, { recursive: true, force: true }); + await runMaterialization(approved); } let actual: string; diff --git a/backend/src/services/git/redirectPreflight.ts b/backend/src/services/git/redirectPreflight.ts new file mode 100644 index 00000000..1451256c --- /dev/null +++ b/backend/src/services/git/redirectPreflight.ts @@ -0,0 +1,203 @@ +import https from 'https'; +import type { TransportFailure } from './errors'; +import { credentialScopeHost } from './caBundle'; +import { sanitizeForLog } from '../../utils/safeLog'; + +/** + * Destination-aware redirect policy for HTTPS Git operations. + * + * Git is always run with `http.followRedirects=false`, so it never contacts a + * redirect target on its own. When git refuses a redirect, this module walks + * the chain itself with an UNAUTHENTICATED request and validates every hop + * before anything follows it. Only a chain that satisfies the policy end to + * end produces a URL git is then re-run against. + * + * The ordering is the point: a destination outside the configured + * repository's origin is rejected while it has still never been contacted, so + * a hostile server cannot use a redirect either to move a credential or to + * turn a repository fetch into a probe of a host it chose. Parsing git's own + * output cannot achieve this, because git prints the destination only on the + * path where it has already followed the redirect (`warning: redirecting to + * ` in git-remote-http) and prints nothing at all when following is + * disabled. + * + * The rule every hop must satisfy is deliberately one rule: the destination + * stays on the configured repository's origin (scheme, host, and port), and + * only the path may move. That is what a repository relocating to a canonical + * path looks like, it is what git's own `update_url_from_redirect` superset + * check enforces on the path component, and it makes a redirect to an + * internal address structurally impossible rather than something a separate + * address-range blocklist has to anticipate. Such a blocklist would also be + * actively wrong here: a self-hosted Git server on loopback or a private LAN + * range is a supported deployment, not an attack. + */ + +/** Hop ceiling for one chain, bounding both loops and probe cost. */ +export const MAX_REDIRECT_HOPS = 5; + +/** The smart-HTTP endpoint whose redirect defines where the repository moved. */ +const REF_ADVERTISE_SUFFIX = '/info/refs'; +const REF_ADVERTISE_QUERY = 'service=git-upload-pack'; +const PROBE_TIMEOUT_MS = 10_000; + +function redirectScope(host: string, hasToken: boolean): TransportFailure { + return { transportFailure: true as const, reason: 'redirect-scope', host, hasToken }; +} + +/** Parse a Location value (absolute or relative) against its base. Null on failure, so callers fail closed. */ +export function resolveLocation(baseUrl: string, location: string): string | null { + try { + return new URL(location, baseUrl).toString(); + } catch { + return null; + } +} + +/** + * The origin a redirect is allowed to stay on, in the same `host[:port]` + * spelling the credential helper compares against, so the transport's + * redirect rule and its credential rule cannot drift apart. + */ +export function redirectScopeOf(url: string): string | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') return null; + return credentialScopeHost(parsed.hostname, parsed.port ? Number(parsed.port) : undefined); + } catch { + return null; + } +} + +/** + * True when git's stderr describes a refused redirect rather than an ordinary + * failure. Matched against git's current wording, which + * `git-redirect-preflight.test.ts` pins to the literal strings git emits, so a + * git upgrade that rephrases them fails a test rather than quietly making + * relocated repositories unreachable. A miss is safe but not silent: no retry + * is authorised and git's own error is reported unchanged. + */ +export function looksLikeRedirectFailure(stderr: string): boolean { + return /returned error: 30\d/i.test(stderr) || /\bredirect/i.test(stderr); +} + +/** + * The single gate every URL passes before it is requested. Returns the URL + * only when it sits on `expectedScope`, and throws otherwise, so no caller can + * reach the network with a destination that has not been checked. The seed URL + * goes through it too: that check is trivially true, but routing every request + * through one place is what makes the guarantee inspectable rather than a + * property of the loop's shape, for a reader as much as for static analysis. + */ +export function approvedUrl( + url: string, + expectedScope: string, + reportHost: string, + hasToken: boolean, +): string { + if (redirectScopeOf(url) !== expectedScope) { + throw redirectScope(reportHost, hasToken); + } + return url; +} + +interface ProbeResponse { + status: number; + location: string | null; +} + +/** One unauthenticated GET, following nothing. Rejects only on transport errors. */ +function probe(url: string, ca: string | undefined): Promise { + return new Promise((resolve, reject) => { + const req = https.get(url, { ca, timeout: PROBE_TIMEOUT_MS }, (res) => { + const location = typeof res.headers.location === 'string' ? res.headers.location : null; + // The body is irrelevant; discard it so the socket can close. + res.resume(); + resolve({ status: res.statusCode ?? 0, location }); + }); + req.on('timeout', () => req.destroy(new Error('redirect preflight timed out'))); + req.on('error', reject); + }); +} + +/** + * Strip the ref-advertise suffix off a probe URL to recover the repository + * URL git should be pointed at. A destination that no longer ends in the + * endpoint we asked for is not a relocation of this repository and is + * refused, mirroring git's own superset rule on the path component. + */ +function repoUrlFromProbeUrl(probeUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(probeUrl); + } catch { + return null; + } + if (!parsed.pathname.endsWith(REF_ADVERTISE_SUFFIX)) return null; + parsed.pathname = parsed.pathname.slice(0, -REF_ADVERTISE_SUFFIX.length); + parsed.search = ''; + parsed.hash = ''; + return parsed.toString().replace(/\/$/, ''); +} + +/** + * Build the ref-advertise probe URL for `repoUrl` via the `URL` API rather + * than string concatenation, so a `repoUrl` that already carries a query + * string (a signed URL, say) gets the suffix inserted into the path and the + * query replaced, instead of the suffix landing after the existing query. + */ +function refAdvertiseProbeUrl(repoUrl: string): string { + const parsed = new URL(repoUrl); + parsed.pathname = `${parsed.pathname.replace(/\/$/, '')}${REF_ADVERTISE_SUFFIX}`; + parsed.search = `?${REF_ADVERTISE_QUERY}`; + return parsed.toString(); +} + +/** + * Walk the redirect chain for `repoUrl` without credentials and return the + * repository URL it ultimately resolves to, or null when the source does not + * redirect at all (so the caller keeps git's original failure). + * + * Throws a `redirect-scope` TransportFailure as soon as a hop leaves the + * configured origin, before that hop is ever requested. + */ +export async function resolveRedirectedRepoUrl(opts: { + repoUrl: string; + hasToken: boolean; + reportHost: string; + caPem?: string; +}): Promise { + const expectedScope = redirectScopeOf(opts.repoUrl); + if (!expectedScope) throw redirectScope(opts.reportHost, opts.hasToken); + + let current = approvedUrl( + refAdvertiseProbeUrl(opts.repoUrl), + expectedScope, opts.reportHost, opts.hasToken, + ); + let hops = 0; + + while (hops < MAX_REDIRECT_HOPS) { + let res: ProbeResponse; + try { + res = await probe(current, opts.caPem); + } catch (e) { + // The probe could not complete (TLS, DNS, reset). We cannot prove + // the chain is safe, so we do not authorise a retry; the caller + // reports git's original error instead. Say why, or a private CA + // that fails to validate is indistinguishable from a server that + // simply does not redirect. + console.warn(`[GitSource:redirect] could not probe ${sanitizeForLog(opts.reportHost)} for a redirect target, keeping the original git error: ${sanitizeForLog(e instanceof Error ? e.message : String(e))}`); + return null; + } + if (res.status < 300 || res.status >= 400 || !res.location) { + if (hops === 0) return null; + const resolved = repoUrlFromProbeUrl(current); + if (!resolved) throw redirectScope(opts.reportHost, opts.hasToken); + return resolved; + } + const next = resolveLocation(current, res.location); + if (!next) throw redirectScope(opts.reportHost, opts.hasToken); + current = approvedUrl(next, expectedScope, opts.reportHost, opts.hasToken); + hops += 1; + } + throw redirectScope(opts.reportHost, opts.hasToken); +} diff --git a/backend/src/services/git/sshTrust.ts b/backend/src/services/git/sshTrust.ts index 4a60863e..984b4ac7 100644 --- a/backend/src/services/git/sshTrust.ts +++ b/backend/src/services/git/sshTrust.ts @@ -68,7 +68,7 @@ export function parseSshUrl(raw: string): ParsedSshRepoUrl | null { if (pathname === '/' || pathname.includes('..')) return null; const user = url.username; const href = port === DEFAULT_SSH_PORT - ? `${user}@${url.hostname}:${pathname.slice(1)}` + ? `${user}@${url.hostname}:${pathname}` : `ssh://${user}@${url.hostname}:${port}${pathname}`; return { href, host: url.hostname, port, pathname }; } @@ -206,11 +206,11 @@ export interface ScannedHostKey { line: string; } -function runSshKeyscan(host: string, port: number): Promise<{ stdout: string; stderr: string; exitCode: number }> { +function runSshKeyscan(address: string, port: number): Promise<{ stdout: string; stderr: string; exitCode: number }> { return new Promise((resolve, reject) => { const args = port === DEFAULT_SSH_PORT - ? ['-H', host] - : ['-p', String(port), '-H', host]; + ? [address] + : ['-p', String(port), address]; const child = spawn('ssh-keyscan', args, { windowsHide: true }); let stdout = ''; let stderr = ''; @@ -231,8 +231,8 @@ function runSshKeyscan(host: string, port: number): Promise<{ stdout: string; st } /** Fetch host keys from the server without trusting them (probe step only). */ -export async function scanHostKeys(host: string, port: number): Promise { - const result = await runSshKeyscan(host, port); +export async function scanHostKeys(host: string, port: number, address: string): Promise { + const result = await runSshKeyscan(address, port); if (result.exitCode !== 0 && !result.stdout.trim()) { throw new Error(result.stderr.trim() || 'ssh-keyscan failed'); } @@ -240,11 +240,13 @@ export async function scanHostKeys(host: string, port: number): Promise(); + + private constructor() { } + + static getInstance(): SourceController { + if (!SourceController.instance) { + SourceController.instance = new SourceController(); + } + return SourceController.instance; + } + + /** Test-only: replace the singleton so timer/in-flight state never leaks between tests. */ + static resetForTests(): void { + SourceController.instance = new SourceController(); + } + + start(): void { + // Guards on `polling`, not `timer`: tick() nulls `timer` before it + // scans (so a stale timer reference can never block a restart), which + // would otherwise let a start() call landing during that scan see a + // false "not running" reading and arm a second timer. + if (this.polling) return; + this.polling = true; + this.armNext(); + } + + stop(): void { + this.cancelPending(); + this.polling = false; + } + + /** + * Reschedule the next tick without restarting: always clears any pending + * timer first, so calling this any number of times in a row never leaves + * more than one timer armed. + */ + restartPolling(): void { + this.cancelPending(); + if (this.polling) { + this.armNext(); + } + } + + isPolling(): boolean { + return this.polling; + } + + /** Clear any armed timer and invalidate the tick it would have run. */ + private cancelPending(): void { + this.scheduleGeneration++; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private armNext(): void { + const gen = this.scheduleGeneration; + this.timer = setTimeout(() => { void this.tick(gen); }, SourceController.TICK_INTERVAL_MS); + this.timer.unref(); + } + + private async tick(gen: number): Promise { + if (!this.polling || gen !== this.scheduleGeneration) return; + this.timer = null; + try { + this.scan(); + } catch (e) { + console.error('[SourceController] scan failed:', e instanceof Error ? e.message : String(e)); + } finally { + if (this.polling && gen === this.scheduleGeneration) { + this.armNext(); + } + } + } + + /** + * Fire an evaluation for every due application without waiting for any + * of them. An application already in the in-flight set is left for a + * later tick instead of being queued behind its own still-running + * evaluation. A row due for both poll and retry is evaluated once. + */ + private scan(): void { + const now = Date.now(); + const store = GitOpsStore.getInstance(); + const due = new Map(); + for (const app of store.listSourcesDueForPoll(now)) due.set(app.id, app); + for (const app of store.listApplicationsDueForRetry(now)) due.set(app.id, app); + + for (const app of due.values()) { + if (this.inFlight.has(app.id)) continue; + this.inFlight.add(app.id); + this.evaluate(app).finally(() => this.inFlight.delete(app.id)); + } + } + + private async evaluate(app: GitOpsApplicationRow): Promise { + if (!app.stack_name) { + console.warn(`[SourceController] Skipping ${sanitizeForLog(app.id)}: direct-mode application has no stack_name.`); + return; + } + const isRetry = app.retry_at !== null && app.retry_at <= Date.now(); + try { + await GitSourceService.getInstance().reconcile({ + intent: 'fetch', + applicationId: app.id, + stackName: app.stack_name, + trigger: isRetry ? 'retry' : 'poll', + actor: 'system:source-controller', + }); + } catch (e) { + console.error( + `[SourceController] evaluation failed for ${sanitizeForLog(app.id)}:`, + e instanceof Error ? e.message : String(e), + ); + } + } +} diff --git a/backend/src/services/gitops/backoff.ts b/backend/src/services/gitops/backoff.ts new file mode 100644 index 00000000..a1e2ff22 --- /dev/null +++ b/backend/src/services/gitops/backoff.ts @@ -0,0 +1,140 @@ +import type { GitSourceErrorCode } from '../GitSourceService'; +import type { TransportFailureReason } from '../git/errors'; + +/** + * Everything a controller attempt can fail with. `git_source_error` covers + * every GitSourceError the fetch/apply path can throw, transport-classified + * or not. The remaining kinds cover dispatch-stage failures that have no + * GitSourceError at all (target binding, target availability, a deploy or + * health failure after a successful apply, Blueprint evaluation, and an + * interrupted or unknown-completion operation). + */ +export type FailureEvidence = + | { kind: 'git_source_error'; code: GitSourceErrorCode; transportReason?: TransportFailureReason } + | { kind: 'policy_unavailable' } + | { kind: 'persistence_unavailable' } + | { kind: 'target_binding_invalid' } + | { kind: 'target_unavailable' } + | { kind: 'target_mutation_failed' } + | { kind: 'blueprint_unavailable' } + | { kind: 'interrupted' }; + +export type FailureDisposition = + /** A newer revision replaced the one being worked on; re-resolve, not backoff. */ + | { class: 'supersession' } + /** Retryable. retryCeiling bounds how many attempts before it escalates to permanent. */ + | { class: 'transient'; retryCeiling: number } + /** Will never succeed by retrying; needs a configuration, environment, or credential change. */ + | { class: 'permanent' } + /** A human decision is required (review a plan, resolve a conflict); not retried automatically. */ + | { class: 'operator_action_required' } + /** Evidence could not be produced (e.g. scanner unavailable); held for review, not retried blind. */ + | { class: 'degraded' } + /** The target itself will never accept this generation without a configuration change. */ + | { class: 'target_permanent' } + /** The target is temporarily unreachable; retryable at the target/dispatch stage only. */ + | { class: 'target_transient' } + /** Applied but deploy or health failed: never refetch or reapply, only redeploy. */ + | { class: 'target_mutation_failed' } + /** Blocked pending a capability this program does not yet provide (e.g. Blueprint rollout). */ + | { class: 'blocked' } + /** Ambiguous or interrupted; reconcile from durable state, never blind retry. */ + | { class: 'reconcile' }; + +export const DEFAULT_TRANSIENT_CEILING = 8; +export const LOW_TRANSIENT_CEILING = 3; + +const TRANSIENT_DEFAULT: FailureDisposition = { class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING }; +const TRANSIENT_LOW: FailureDisposition = { class: 'transient', retryCeiling: LOW_TRANSIENT_CEILING }; +const PERMANENT: FailureDisposition = { class: 'permanent' }; + +/** + * Total over every TransportFailureReason except `exit`, which is generic + * and needs the classified GitSourceErrorCode (see CODE_DISPOSITION) to + * tell a transient network condition from a rate limit from an + * unrecognized error. Adding a new reason to the source union without + * adding it here fails the build. + */ +const REASON_DISPOSITION: Record, FailureDisposition> = { + 'tip-changed': { class: 'supersession' }, + timeout: TRANSIENT_DEFAULT, + 'target-unresolved': TRANSIENT_DEFAULT, + 'invalid-url': PERMANENT, + 'unsafe-target': PERMANENT, + 'invalid-ref': PERMANENT, + 'redirect-scope': PERMANENT, + 'git-missing': PERMANENT, + 'git-old': PERMANENT, + size: PERMANENT, + 'ssh-auth-required': PERMANENT, + 'ref-not-found': PERMANENT, + 'unsupported-ref': PERMANENT, +}; + +/** + * Total over every GitSourceErrorCode. Used directly when there is no + * transport reason (a plan/validation/file/operation-conflict error), and + * as the exit-reason fallback (RATE_LIMITED, NETWORK_TIMEOUT, and GIT_ERROR + * only ever arise from an `exit` transport reason). Adding a new code + * without adding it here fails the build. + */ +const CODE_DISPOSITION: Record = { + REPO_NOT_FOUND: PERMANENT, + AUTH_FAILED: PERMANENT, + REF_NOT_FOUND: PERMANENT, + REF_DELETED: PERMANENT, + UNSUPPORTED_REF: PERMANENT, + SSH_HOST_KEY_FAILED: PERMANENT, + FILE_NOT_FOUND: { class: 'operator_action_required' }, + RATE_LIMITED: TRANSIENT_DEFAULT, + NETWORK_TIMEOUT: TRANSIENT_DEFAULT, + GIT_ERROR: TRANSIENT_LOW, + STALE_PLAN: { class: 'operator_action_required' }, + PLAN_FINGERPRINT_REQUIRED: { class: 'operator_action_required' }, + PLAN_BLOCKED: { class: 'operator_action_required' }, + LEGACY_PENDING: { class: 'operator_action_required' }, + PLAN_UNAVAILABLE: { class: 'operator_action_required' }, + OPERATION_IN_FLIGHT: { class: 'reconcile' }, +}; + +export function classifyFailure(evidence: FailureEvidence): FailureDisposition { + switch (evidence.kind) { + case 'git_source_error': + if (evidence.transportReason && evidence.transportReason !== 'exit') { + return REASON_DISPOSITION[evidence.transportReason]; + } + return CODE_DISPOSITION[evidence.code]; + case 'policy_unavailable': + return { class: 'degraded' }; + case 'persistence_unavailable': + return TRANSIENT_DEFAULT; + case 'target_binding_invalid': + return { class: 'target_permanent' }; + case 'target_unavailable': + return { class: 'target_transient' }; + case 'target_mutation_failed': + return { class: 'target_mutation_failed' }; + case 'blueprint_unavailable': + return { class: 'blocked' }; + case 'interrupted': + return { class: 'reconcile' }; + } +} + +const BASE_DELAY_MS = 60_000; +const MAX_DELAY_MS = 3_600_000; +const JITTER_RATIO = 0.1; + +/** + * Bounded exponential backoff with jitter: 60s * 2^retryCount, capped at one + * hour, with up to +-10% jitter so many sources retrying at once do not + * all land on the same second. A provider-supplied retry floor (e.g. a + * rate-limit Retry-After) takes precedence whenever it is larger than the + * computed delay. + */ +export function nextRetryAt(now: number, retryCount: number, providerFloorMs?: number): number { + const capped = Math.min(BASE_DELAY_MS * 2 ** retryCount, MAX_DELAY_MS); + const jittered = capped + capped * JITTER_RATIO * (Math.random() * 2 - 1); + const delay = providerFloorMs !== undefined ? Math.max(jittered, providerFloorMs) : jittered; + return now + delay; +} diff --git a/backend/src/services/gitops/blueprintProducers.ts b/backend/src/services/gitops/blueprintProducers.ts index a08fd4b2..8dfc5013 100644 --- a/backend/src/services/gitops/blueprintProducers.ts +++ b/backend/src/services/gitops/blueprintProducers.ts @@ -341,7 +341,7 @@ export function commitBlueprintDelete(blueprintId: number, actor: string | null) } /** A Blueprint application before anything has been asked of it. */ -export function blankInlineApplication(id: string, blueprintId: number, at: number) { +export function blankInlineApplication(id: string, blueprintId: number, at: number): GitOpsApplicationRow { return { id, lifecycle_key: `blueprint:${blueprintId}`, @@ -381,6 +381,11 @@ export function blankInlineApplication(id: string, blueprintId: number, at: numb active_generation_id: null, pause_at: null, pause_reason: null, + source_suspended_reason: null, + source_policy: 'manual', + poll_interval_secs: null, + next_poll_at: null, + attempt_seq: 0, partial_json: null, failure_stage: null, failure_class: null, diff --git a/backend/src/services/gitops/createRecovery.ts b/backend/src/services/gitops/createRecovery.ts index 3972a80f..3cd71f53 100644 --- a/backend/src/services/gitops/createRecovery.ts +++ b/backend/src/services/gitops/createRecovery.ts @@ -267,6 +267,7 @@ async function resolveOne(checkpoint: GitOpsCreateCheckpointRow): Promise; +}; + +/** Authored Compose invocation shape, without a target project name. */ +export type ComposeInputs = { + composeFileOrder: string[]; + profiles?: string[]; + contextDir?: string | null; +}; + +/** + * One accepted generation, described purely by what it contains. Direct and + * Blueprint dispatch consume the exact same shape; current target mode, + * binding revision, and any execution-local path travel separately in + * DispatchContext, re-read under the dispatch lock rather than carried + * here, so a stale acceptance can never authorize a routing decision made + * after it. + */ +export type AcceptedGeneration = { + contractVersion: 1; + generationId: string; + applicationId: string; + repoIdentity: RepoIdentity; + configuredRef: string; + commitSha: string; + resolvedRefKind: RefKind | null; + manifestVersion: number; + portableManifest: PortableManifest | null; + composeInputs: ComposeInputs | null; + materializationFingerprint: string; + changePlanFingerprint: string | null; + validationOk: boolean; + sourcePolicyEvidence: unknown | null; + securityPolicyEvidence: unknown | null; + supportRequirements: unknown | null; + compatibilityRequirements: unknown | null; + /** Capability metadata only; never a secret value. Not yet populated by any producer. */ + secretCapability: unknown | null; + trigger: string; + actor: string | null; + operationId: string; + previousGenerationId: string | null; + /** Why some field above could not be proven, recorded honestly rather than guessed. */ + limitations: string[]; +}; + +function parseOptionalJson(raw: string | null, limitationLabel: string, limitations: string[]): T | null { + if (raw === null) { + limitations.push(limitationLabel); + return null; + } + try { + return JSON.parse(raw) as T; + } catch { + limitations.push(`${limitationLabel}_unparseable`); + return null; + } +} + +/** + * Build the portable accepted-generation contract from a persisted + * generation row. A legacy row predating one of the portable-contract + * columns decodes that field as null with an explicit limitation recorded, + * never as invented evidence. + */ +export function buildAcceptedGeneration(row: GitOpsGenerationRow): AcceptedGeneration { + let repoIdentity: RepoIdentity; + try { + repoIdentity = JSON.parse(row.repo_identity_json) as RepoIdentity; + } catch { + throw new Error(`Generation ${row.id} has an unparseable repo_identity_json; refusing to build an accepted-generation contract from corrupt evidence.`); + } + + const limitations: string[] = JSON.parse(row.redacted_limitations_json) as string[]; + const portableManifest = parseOptionalJson(row.portable_manifest_json, 'portable_manifest_missing', limitations); + const composeInputs = parseOptionalJson(row.compose_inputs_json, 'compose_inputs_missing', limitations); + const sourcePolicyEvidence = parseOptionalJson(row.source_policy_evidence_json, 'source_policy_evidence_missing', limitations); + const securityPolicyEvidence = parseOptionalJson(row.security_policy_evidence_json, 'security_policy_evidence_missing', limitations); + const supportRequirements = parseOptionalJson(row.support_requirements_json, 'support_requirements_missing', limitations); + const compatibilityRequirements = parseOptionalJson(row.compatibility_requirements_json, 'compatibility_requirements_missing', limitations); + + return { + contractVersion: 1, + generationId: row.id, + applicationId: row.application_id, + repoIdentity, + configuredRef: row.configured_ref, + commitSha: row.commit_sha, + resolvedRefKind: row.resolved_ref_kind, + manifestVersion: row.manifest_version, + portableManifest, + composeInputs, + materializationFingerprint: row.materialization_fingerprint, + changePlanFingerprint: row.change_plan_fingerprint, + validationOk: row.validation_ok === 1, + sourcePolicyEvidence, + securityPolicyEvidence, + supportRequirements, + compatibilityRequirements, + secretCapability: null, + trigger: row.trigger, + actor: row.actor, + operationId: row.operation_id, + previousGenerationId: row.previous_generation_id, + limitations, + }; +} + +/** + * Current target mode and binding, re-read under the dispatch lock rather + * than carried on AcceptedGeneration, so a routing decision is always made + * from the current state, never a value an earlier acceptance froze. + */ +export type DispatchContext = { + targetMode: 'direct' | 'blueprint'; + nodeId: number | null; + bindingRevision: string | null; +}; + +export type DispatchResult = + | { status: 'dispatched' } + | { status: 'blocked'; reason: string }; + +export interface TargetAdapter { + dispatch(generation: AcceptedGeneration, context: DispatchContext): Promise; +} + +/** + * Fails closed until Blueprint rollout orchestration exists. Never + * inspects selectors, target sets, or placement: an accepted generation + * for a Blueprint-mode application is evaluated the same as Direct, but + * dispatch stops here. + */ +export class BlueprintTargetAdapter implements TargetAdapter { + async dispatch(_generation: AcceptedGeneration, _context: DispatchContext): Promise { + return { status: 'blocked', reason: 'Blueprint rollout orchestration is not yet implemented.' }; + } +} diff --git a/backend/src/services/gitops/history.ts b/backend/src/services/gitops/history.ts index 2695d73f..65ef7005 100644 --- a/backend/src/services/gitops/history.ts +++ b/backend/src/services/gitops/history.ts @@ -68,10 +68,14 @@ export type GitOpsHistoryStage = | 'rollout_candidate_opened' | 'rollout_paused' | 'rollout_unpaused' + | 'source_accepted' | 'source_conflict_blocker' + | 'source_reconcile_started' + | 'source_reconcile_settled' | 'source_retry_scheduled' | 'source_suspended' | 'source_unsuspended' + | 'target_applied' | 'target_tombstoned'; export type HistoryInsert = { diff --git a/backend/src/services/gitops/migrate.ts b/backend/src/services/gitops/migrate.ts index 002a3652..aa7a6e6e 100644 --- a/backend/src/services/gitops/migrate.ts +++ b/backend/src/services/gitops/migrate.ts @@ -226,6 +226,12 @@ function migrateAccepted( actor: envelope.actor, previous_generation_id: null, redacted_limitations_json: '[]', + portable_manifest_json: null, + compose_inputs_json: null, + source_policy_evidence_json: null, + security_policy_evidence_json: null, + support_requirements_json: null, + compatibility_requirements_json: null, created_at: envelope.at, }; store.insertGeneration(generation); diff --git a/backend/src/services/gitops/outcomes.ts b/backend/src/services/gitops/outcomes.ts new file mode 100644 index 00000000..0b72babb --- /dev/null +++ b/backend/src/services/gitops/outcomes.ts @@ -0,0 +1,211 @@ +import type { SourceFacet } from './types'; + +/** + * Normalized reconcile outcomes. Silence is not an acceptable GitOps + * result: every attempt settles into exactly one of these, never a bare + * success/failure boolean. + * + * `converged` is deliberately never produced by outcomeFromSourceFacet: it + * requires target and health evidence this source-only projection does not + * have, and "no source change" is not proof of full convergence. A later + * composition over source + target + health facets is what may report it. + */ +export type ReconcileOutcome = + | 'converged' + | 'no_source_change' + | 'candidate_already_fetched' + | 'pending_review' + | 'suspended' + | 'retry_scheduled' + | 'blocked' + | 'superseded' + | 'failed_previous_intact' + | 'recovery_required' + | 'unknown'; + +export type NextAction = + | 'none' + | 'review' + | 'resume' + | 'retry' + | 'resolve_conflict' + | 'configure_credentials' + | 'view_target_results'; + +export type ReconcileResult = { + outcome: ReconcileOutcome; + reason: string; + nextAction: NextAction; + retryAt?: number; + commitSha?: string; +}; + +/** Every ReconcileOutcome member, for runtime validation of a value read back from storage. */ +const RECONCILE_OUTCOMES: ReadonlySet = new Set([ + 'converged', + 'no_source_change', + 'candidate_already_fetched', + 'pending_review', + 'suspended', + 'retry_scheduled', + 'blocked', + 'superseded', + 'failed_previous_intact', + 'recovery_required', + 'unknown', +]); + +/** Every NextAction member, for runtime validation of a value read back from storage. */ +const NEXT_ACTIONS: ReadonlySet = new Set([ + 'none', + 'review', + 'resume', + 'retry', + 'resolve_conflict', + 'configure_credentials', + 'view_target_results', +]); + +export function isReconcileOutcome(value: unknown): value is ReconcileOutcome { + return typeof value === 'string' && RECONCILE_OUTCOMES.has(value); +} + +export function isNextAction(value: unknown): value is NextAction { + return typeof value === 'string' && NEXT_ACTIONS.has(value); +} + +function commitShaOf(facet: Extract): string | undefined { + return facet.desiredCommitSha ?? facet.fetchedCommitSha ?? undefined; +} + +/** + * Derive the normalized outcome of a settled source reconcile attempt from + * the existing source-facet projection, rather than re-deriving status from + * raw application-row fields. Keeps the outcome vocabulary and the + * projection's own status vocabulary from silently drifting apart. + */ +export function outcomeFromSourceFacet(facet: SourceFacet): ReconcileResult { + switch (facet.status) { + case 'not_applicable': + return { outcome: 'unknown', reason: 'No GitOps application exists for this stack.', nextAction: 'none' }; + + case 'never_reconciled': + return { outcome: 'unknown', reason: 'The source has never been reconciled.', nextAction: 'none' }; + + case 'checking_fetching': + case 'applying': + return { + outcome: 'unknown', + reason: 'A reconcile operation is currently in flight; no settled result yet.', + nextAction: 'none', + commitSha: commitShaOf(facet), + }; + + case 'source_reconcile_required': + return { + outcome: 'unknown', + reason: 'The source has advanced but reconciliation has not evaluated it yet.', + nextAction: 'none', + commitSha: commitShaOf(facet), + }; + + case 'application_generation_accepted': + return { + outcome: 'no_source_change', + reason: 'The configured ref still resolves to the accepted generation. This is not proof of full convergence.', + nextAction: 'none', + commitSha: commitShaOf(facet), + }; + + case 'candidate_ready': + return { + outcome: 'candidate_already_fetched', + reason: 'A candidate generation is already staged and awaiting acceptance.', + nextAction: 'none', + commitSha: commitShaOf(facet), + }; + + case 'source_review_pending': + return { + outcome: 'pending_review', + reason: 'A candidate is staged and requires explicit review before acceptance.', + nextAction: 'review', + commitSha: commitShaOf(facet), + }; + + case 'source_conflict_blocker': + return { + outcome: 'blocked', + reason: 'A local conflict is blocking the candidate from being accepted.', + nextAction: 'resolve_conflict', + commitSha: commitShaOf(facet), + }; + + case 'source_superseded': + return { + outcome: 'superseded', + reason: 'A newer revision superseded this candidate before it was accepted.', + nextAction: 'none', + commitSha: commitShaOf(facet), + }; + + case 'source_retry_scheduled': + return { + outcome: 'retry_scheduled', + reason: `A previous attempt failed transiently; retry ${facet.retryCount + 1} is scheduled.`, + nextAction: 'none', + retryAt: facet.retryAt, + commitSha: commitShaOf(facet), + }; + + case 'source_suspended': + return { + outcome: 'suspended', + reason: facet.suspendedReason + ? `Reconciliation is suspended: ${facet.suspendedReason}` + : 'Reconciliation is suspended.', + nextAction: 'resume', + commitSha: commitShaOf(facet), + }; + + case 'source_failed': + return { + outcome: 'failed_previous_intact', + reason: `The ${facet.failureStage} stage failed (${facet.failureClass}). The previously accepted generation is unchanged.`, + nextAction: facet.retryAt !== null ? 'retry' : 'configure_credentials', + retryAt: facet.retryAt ?? undefined, + commitSha: commitShaOf(facet), + }; + + case 'source_unknown': + return { + outcome: 'recovery_required', + reason: `An operation was interrupted at ${facet.interruptedStage} and its outcome is unproven.`, + nextAction: 'view_target_results', + commitSha: commitShaOf(facet), + }; + + case 'recovery_required': + return { + outcome: 'recovery_required', + reason: 'Recovery from an earlier failed mutation is still outstanding.', + nextAction: 'view_target_results', + commitSha: commitShaOf(facet), + }; + + case 'recovery_failed': + return { + outcome: 'recovery_required', + reason: `Recovery itself failed (${facet.failureClass}); this needs operator attention.`, + nextAction: 'view_target_results', + commitSha: commitShaOf(facet), + }; + + case 'not_live': + return { + outcome: 'unknown', + reason: `The application is ${facet.lifecycleStatus}, not live; there is nothing to reconcile.`, + nextAction: 'none', + }; + } +} diff --git a/backend/src/services/gitops/schema.ts b/backend/src/services/gitops/schema.ts index 4acdcf32..d881ed3e 100644 --- a/backend/src/services/gitops/schema.ts +++ b/backend/src/services/gitops/schema.ts @@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS gitops_create_checkpoints ( encrypted_deploy_key TEXT NULL, ssh_known_hosts_entry TEXT NULL, ssh_host_key_fingerprint TEXT NULL, + encrypted_ca_bundle TEXT NULL, auto_apply_on_webhook INTEGER NOT NULL DEFAULT 0, auto_deploy_on_apply INTEGER NOT NULL DEFAULT 0, commit_sha TEXT NULL, @@ -94,6 +95,21 @@ CREATE TABLE IF NOT EXISTS gitops_applications ( active_generation_id TEXT NULL, pause_at INTEGER NULL, pause_reason TEXT NULL, + -- Distinct from pause_reason: sourceSuspended/sourceUnsuspended write this + -- field, not the one rolloutPaused/rolloutUnpaused share across app and + -- target rows, so suspending a source can never clobber an unrelated + -- rollout pause reason (or the reverse). + source_suspended_reason TEXT NULL, + -- Controller-owned bookkeeping. NULL poll_interval_secs inherits the + -- global default; 0 disables polling for this application. next_poll_at + -- is the durable scheduling cursor. attempt_seq is allocated + -- transactionally per submission lacking a stable external delivery id. + source_policy TEXT NOT NULL DEFAULT 'manual' CHECK ( + source_policy IN ('manual','review','automatic') + ), + poll_interval_secs INTEGER NULL, + next_poll_at INTEGER NULL, + attempt_seq INTEGER NOT NULL DEFAULT 0, partial_json TEXT NULL, failure_stage TEXT NULL CHECK ( failure_stage IS NULL OR failure_stage IN ( @@ -171,6 +187,17 @@ CREATE TABLE IF NOT EXISTS gitops_generations ( actor TEXT NULL, previous_generation_id TEXT NULL, redacted_limitations_json TEXT NOT NULL DEFAULT '[]', + -- Portable accepted-generation contract (content only: no node id, local + -- path, target mode, or secret value). Additive and nullable so existing + -- rows decode as an explicit limitation rather than invented evidence; a + -- legacy pending candidate lacking these must be re-evaluated before it + -- can be accepted or dispatched. + portable_manifest_json TEXT NULL, + compose_inputs_json TEXT NULL, + source_policy_evidence_json TEXT NULL, + security_policy_evidence_json TEXT NULL, + support_requirements_json TEXT NULL, + compatibility_requirements_json TEXT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_gitops_gen_app_created @@ -447,6 +474,10 @@ CREATE INDEX IF NOT EXISTS idx_gitops_history_node ON gitops_history(node_id); CREATE INDEX IF NOT EXISTS idx_gitops_history_trigger ON gitops_history(trigger); CREATE INDEX IF NOT EXISTS idx_gitops_history_actor ON gitops_history(actor); CREATE INDEX IF NOT EXISTS idx_gitops_history_outcome ON gitops_history(outcome); +-- listUnsettledReconcileAttempts filters on stage and orders by created_at; +-- without this, that query (run on every startup, ahead of the server +-- listening) scans and sorts the whole table. +CREATE INDEX IF NOT EXISTS idx_gitops_history_stage_created ON gitops_history(stage, created_at); CREATE INDEX IF NOT EXISTS idx_gitops_history_repo_ref ON gitops_history(repo_url, configured_ref); CREATE INDEX IF NOT EXISTS idx_gitops_history_stack_created diff --git a/backend/src/services/gitops/store.ts b/backend/src/services/gitops/store.ts index 0ea3d621..f5199f92 100644 --- a/backend/src/services/gitops/store.ts +++ b/backend/src/services/gitops/store.ts @@ -1,5 +1,6 @@ import type Database from 'better-sqlite3'; import { DatabaseService } from '../DatabaseService'; +import type { GitOpsHistoryCursor } from './history'; import { decodeArtifactEvidenceJson, decodeGitOpsApprovedTargetEffectJson, @@ -16,6 +17,7 @@ import type { GitOpsCreateCheckpointRow, GitOpsCreatePhase, GitOpsGenerationRow, + GitOpsHistoryRow, GitOpsIntentRevisionRow, GitOpsRolloutCandidateRow, GitOpsTargetCurrentRow, @@ -131,6 +133,29 @@ export class GitOpsStore { ).get(stackName) as GitOpsApplicationRow | undefined; } + /** + * Whether a detached Direct application exists for this stack name, + * distinct from "no application was ever created" (the legitimate + * pre-migration case, where a stack's git-source config predates + * GitOps tracking entirely). `detached` only, matching + * getDetachedDirectApplication above and for the same reason: + * `deleted` is not a safe signal here. Two production paths tombstone + * an application as `deleted` while deliberately preserving its + * git-source row so a future upsert or migration can rebuild from it + * (`gitops/createRecovery.ts`'s checkpointless-create sweep, and + * `gitops/migrate.ts`'s `tombstoned_missing_stack` outcome) -- + * treating that state as a refusal would be permanent and + * unrecoverable, since neither upsert() nor migration currently mints + * a fresh application once a matching migration checkpoint exists. + * `detach()` itself deletes the git-source row in the same transaction + * as tombstoning (`detached`), so the window this method exists to + * catch (tracking removed, config surviving) is a crash between those + * two writes, not routine operation. + */ + hasDetachedDirectApplication(stackName: string): boolean { + return this.getDetachedDirectApplication(stackName) !== undefined; + } + /** Direct applications that never reached their success boundary. */ listCreatingDirectApplications(): GitOpsApplicationRow[] { return this.db().prepare( @@ -144,6 +169,25 @@ export class GitOpsStore { return this.db().prepare('SELECT * FROM gitops_generations WHERE id = ?').get(id) as GitOpsGenerationRow | undefined; } + /** Generations whose creating reconcile attempt has not durably settled. */ + listGenerationsClaimedByUnsettledAttempts(applicationId: string): GitOpsGenerationRow[] { + return this.db().prepare( + `SELECT DISTINCT generation.* + FROM gitops_generations generation + JOIN gitops_history started + ON started.application_id = generation.application_id + AND started.operation_id = generation.operation_id + AND started.stage = 'source_reconcile_started' + WHERE generation.application_id = ? + AND NOT EXISTS ( + SELECT 1 FROM gitops_history settled + WHERE settled.application_id = started.application_id + AND settled.operation_id = started.operation_id + AND settled.stage = 'source_reconcile_settled' + )`, + ).all(applicationId) as GitOpsGenerationRow[]; + } + getArtifactSet(id: string): GitOpsArtifactSetRow | undefined { return this.db().prepare('SELECT * FROM gitops_artifact_sets WHERE id = ?').get(id) as GitOpsArtifactSetRow | undefined; } @@ -195,6 +239,126 @@ export class GitOpsStore { ).all() as GitOpsApplicationRow[]; } + /** + * The settled result for one exact reconcile attempt, or undefined when + * that attempt has not settled (or never existed). Used to recover a + * reservation that already completed rather than repeating the work. + */ + getSettledAttempt(applicationId: string, operationId: string): GitOpsHistoryRow | undefined { + return this.db().prepare( + `SELECT * FROM gitops_history + WHERE application_id = ? AND operation_id = ? AND stage = 'source_reconcile_settled' + LIMIT 1`, + ).get(applicationId, operationId) as GitOpsHistoryRow | undefined; + } + + /** + * The reservation row for one exact reconcile attempt, or undefined when + * it was never reserved. Used to recover this attempt's own recorded + * follower link (if any), so a follower can be settled from its + * leader's actual result rather than derived independently of it. + */ + getStartedAttempt(applicationId: string, operationId: string): GitOpsHistoryRow | undefined { + return this.db().prepare( + `SELECT * FROM gitops_history + WHERE application_id = ? AND operation_id = ? AND stage = 'source_reconcile_started' + LIMIT 1`, + ).get(applicationId, operationId) as GitOpsHistoryRow | undefined; + } + + /** + * Every reservation with no matching settled row, oldest first: an + * attempt that started but never recorded a result, most likely because + * the process crashed between reservation and settlement. Startup + * recovery reconciles these from durable stage evidence rather than + * leaving them silently open forever. + * + * `after` pages strictly forward by (created_at, id), the same cursor + * shape `queryHistoryRows` uses, and is load-bearing rather than + * cosmetic: a row a caller cannot settle (its application vanished, a DB + * error) stays unsettled forever by definition, so without a cursor it + * would occupy the same "oldest N" window on every future call and hide + * every genuinely recoverable row behind it once the backlog exceeds one + * page. + */ + listUnsettledReconcileAttempts(limit = 200, after?: GitOpsHistoryCursor): GitOpsHistoryRow[] { + const clauses = ["started.stage = 'source_reconcile_started'"]; + const params: Array = []; + if (after) { + clauses.push('(started.created_at > ? OR (started.created_at = ? AND started.id > ?))'); + params.push(after.createdAt, after.createdAt, after.id); + } + params.push(limit); + return this.db().prepare( + `SELECT started.* FROM gitops_history started + WHERE ${clauses.join(' AND ')} + AND NOT EXISTS ( + SELECT 1 FROM gitops_history settled + WHERE settled.application_id = started.application_id + AND settled.operation_id = started.operation_id + AND settled.stage = 'source_reconcile_settled' + ) + ORDER BY started.created_at ASC, started.id ASC + LIMIT ?`, + ).all(...params) as GitOpsHistoryRow[]; + } + + /** + * The most recently settled attempt for an application, for API and UI + * projection. Distinct from getSettledAttempt, which looks up one exact + * operation rather than the newest one. + */ + latestSettledAttempt(applicationId: string): GitOpsHistoryRow | undefined { + // rowid (SQLite's implicit insertion-order key), not the id column: id + // is a random UUID and does not sort by recency the way rowid does, so + // it cannot break a created_at tie between two attempts settled within + // the same millisecond. + return this.db().prepare( + `SELECT * FROM gitops_history + WHERE application_id = ? AND stage = 'source_reconcile_settled' + ORDER BY created_at DESC, rowid DESC + LIMIT 1`, + ).get(applicationId) as GitOpsHistoryRow | undefined; + } + + /** + * Direct sources whose poll time has arrived: active, not suspended, no + * operation in flight. Blueprint-mode applications are never polled here + * -- source evaluation for them is blocked at the evaluation boundary + * until an application-keyed source engine exists for that mode. + */ + listSourcesDueForPoll(now: number, limit = 200): GitOpsApplicationRow[] { + return this.db().prepare( + `SELECT * FROM gitops_applications + WHERE target_mode = 'direct' + AND lifecycle_status = 'active' + AND suspended_at IS NULL + AND active_operation_stage IS NULL + AND next_poll_at IS NOT NULL + AND next_poll_at <= ? + ORDER BY next_poll_at ASC + LIMIT ?`, + ).all(now, limit) as GitOpsApplicationRow[]; + } + + /** + * Applications with a scheduled retry that has come due: not suspended, + * no operation in flight. Poll eligibility and retry eligibility are + * deliberately separate queries, since a retry can be due on an + * application whose poll cadence would not otherwise select it yet. + */ + listApplicationsDueForRetry(now: number, limit = 200): GitOpsApplicationRow[] { + return this.db().prepare( + `SELECT * FROM gitops_applications + WHERE retry_at IS NOT NULL + AND retry_at <= ? + AND suspended_at IS NULL + AND active_operation_stage IS NULL + ORDER BY retry_at ASC + LIMIT ?`, + ).all(now, limit) as GitOpsApplicationRow[]; + } + /** Every live target on one node, across all applications. */ listActiveTargetsForNode(nodeId: number): GitOpsTargetCurrentRow[] { return this.db().prepare( @@ -266,14 +430,16 @@ export class GitOpsStore { application_id, stack_name, phase, generation_id, operation_id, repo_url, branch, compose_path, compose_paths_json, context_dir, sync_env, env_path, auth_type, encrypted_token, encrypted_deploy_key, ssh_known_hosts_entry, ssh_host_key_fingerprint, + encrypted_ca_bundle, auto_apply_on_webhook, auto_deploy_on_apply, commit_sha, applied_spec_json, created_managed_root, created_at, updated_at - ) VALUES (${Array(24).fill('?').join(', ')})`, + ) VALUES (${Array(25).fill('?').join(', ')})`, ).run( row.application_id, row.stack_name, row.phase, row.generation_id, row.operation_id, row.repo_url, row.branch, row.compose_path, row.compose_paths_json, row.context_dir, row.sync_env, row.env_path, row.auth_type, row.encrypted_token, row.encrypted_deploy_key, - row.ssh_known_hosts_entry, row.ssh_host_key_fingerprint, row.auto_apply_on_webhook, + row.ssh_known_hosts_entry, row.ssh_host_key_fingerprint, row.encrypted_ca_bundle, + row.auto_apply_on_webhook, row.auto_deploy_on_apply, row.commit_sha, row.applied_spec_json, row.created_managed_root, row.created_at, row.updated_at, ); @@ -363,12 +529,13 @@ export class GitOpsStore { intent_revision_id, rollout_candidate_id, rollout_generation_id, source_acceptance_ref, placement_approval_ref, rollout_authorization_ref, legacy_combined_approval_ref, preflight_fingerprint, latest_operation_id, active_operation_id, active_operation_stage, - active_operation_at, active_generation_id, pause_at, pause_reason, partial_json, + active_operation_at, active_generation_id, pause_at, pause_reason, source_suspended_reason, + source_policy, poll_interval_secs, next_poll_at, attempt_seq, partial_json, failure_stage, failure_class, failure_at, retry_at, retry_count, suspended_at, recovery_ref, recovery_phase, interruption_stage, interruption_at, interruption_operation_id, interruption_generation_id, evidence_fresh_at, evidence_limitations_json, created_at, updated_at - ) VALUES (${Array(55).fill('?').join(', ')})`, + ) VALUES (${Array(60).fill('?').join(', ')})`, ).run( row.id, row.lifecycle_key, row.lifecycle_status, row.target_mode, row.stack_name, row.blueprint_id, row.configured_repo_url, row.repo_identity_json, row.configured_ref, row.compose_paths_json, @@ -378,7 +545,8 @@ export class GitOpsStore { row.intent_revision_id, row.rollout_candidate_id, row.rollout_generation_id, row.source_acceptance_ref, row.placement_approval_ref, row.rollout_authorization_ref, row.legacy_combined_approval_ref, row.preflight_fingerprint, row.latest_operation_id, row.active_operation_id, row.active_operation_stage, - row.active_operation_at, row.active_generation_id, row.pause_at, row.pause_reason, row.partial_json, + row.active_operation_at, row.active_generation_id, row.pause_at, row.pause_reason, row.source_suspended_reason, + row.source_policy, row.poll_interval_secs, row.next_poll_at, row.attempt_seq, row.partial_json, row.failure_stage, row.failure_class, row.failure_at, row.retry_at, row.retry_count, row.suspended_at, row.recovery_ref, row.recovery_phase, row.interruption_stage, row.interruption_at, row.interruption_operation_id, row.interruption_generation_id, row.evidence_fresh_at, @@ -392,13 +560,18 @@ export class GitOpsStore { id, application_id, commit_sha, repo_url, configured_ref, resolved_ref_kind, repo_identity_json, manifest_version, candidate_dir, applied_dir, expected_invocation_json, materialization_fingerprint, validation_ok, plan_blocked, change_plan_fingerprint, - operation_id, trigger, actor, previous_generation_id, redacted_limitations_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + operation_id, trigger, actor, previous_generation_id, redacted_limitations_json, + portable_manifest_json, compose_inputs_json, source_policy_evidence_json, + security_policy_evidence_json, support_requirements_json, compatibility_requirements_json, + created_at + ) VALUES (${Array(27).fill('?').join(', ')})`, ).run( row.id, row.application_id, row.commit_sha, row.repo_url, row.configured_ref, row.resolved_ref_kind, row.repo_identity_json, row.manifest_version, row.candidate_dir, row.applied_dir, row.expected_invocation_json, row.materialization_fingerprint, row.validation_ok, row.plan_blocked, row.change_plan_fingerprint, row.operation_id, row.trigger, row.actor, row.previous_generation_id, row.redacted_limitations_json, + row.portable_manifest_json, row.compose_inputs_json, row.source_policy_evidence_json, + row.security_policy_evidence_json, row.support_requirements_json, row.compatibility_requirements_json, row.created_at, ); } diff --git a/backend/src/services/gitops/transitions.ts b/backend/src/services/gitops/transitions.ts index 973f0acb..91fb286e 100644 --- a/backend/src/services/gitops/transitions.ts +++ b/backend/src/services/gitops/transitions.ts @@ -28,6 +28,10 @@ export type EventEnvelope = { at: number; }; +export type ReconcileDeliveryIntent = + | { autoApply: false; deploy: false } + | { autoApply: true; deploy: boolean }; + export type AppliedArgs = { applicationId: string; generationId: string; @@ -372,28 +376,10 @@ export class GitOpsTransitions { applied(args: AppliedArgs): TransitionResult { return this.mutateApp(args.applicationId, args.envelope, 'applied', 'committed', (app) => { const targets = this.acceptanceTargets(app, args); - this.insertAcceptanceRecords(app, args); - app.accepted_generation_id = args.generationId; - app.artifact_set_id = args.artifactSetId; - app.latest_artifact_set_id = args.artifactSetId; - app.source_acceptance_ref = args.sourceAcceptanceId; - app.candidate_generation_id = null; - app.candidate_plan_blocked = 0; - app.review_required = 0; - if (args.activateCreating && app.lifecycle_status === 'creating') { - app.lifecycle_status = 'active'; - } - this.clearActive(app); - this.clearAppFailure(app, ['apply', 'fetch', 'validation']); - this.clearInterruption(app, 'apply_started'); + this.applySourceAcceptanceMutation(app, args); for (const target of targets) { if (app.target_mode === 'direct') { - target.desired_generation_id = args.generationId; - target.applied_generation_id = args.generationId; - target.expected_artifact_set_id = args.artifactSetId; - target.latest_artifact_set_id = args.artifactSetId; - target.source_acceptance_ref = args.sourceAcceptanceId; - target.candidate_generation_id = null; + this.applyTargetAcceptanceMutation(target, args); } this.store().upsertTarget(target); } @@ -404,6 +390,63 @@ export class GitOpsTransitions { }); } + /** + * Mode-neutral half of `applied`: accept the candidate at the application + * level without binding any target. A Direct dispatch calls this before + * promotion; `targetApplied` binds the target only after promotion commits. + */ + sourceAccepted(args: AppliedArgs): TransitionResult { + return this.mutateApp(args.applicationId, args.envelope, 'source_accepted', 'committed', (app) => { + // Unlike applied() (preserved byte-identical, predates suspension), + // this new entry point is the one a suspended source must refuse: no + // new acceptance while suspended, so the check lives here rather than + // in the shared requireAcceptableCandidate guard. + if (app.suspended_at) throw new GitOpsTransitionError('source is suspended'); + this.requireAcceptableCandidate(app, args); + this.applySourceAcceptanceMutation(app, args); + }, { + generationId: args.generationId, + artifactSetId: args.artifactSetId, + sourceAcceptanceRef: args.sourceAcceptanceId, + }); + } + + /** + * Direct-only half of `applied`: bind one target to an already-accepted + * generation. Refuses a generation the application has not accepted, so a + * dispatch cannot bind a target to source content nothing authorized. + */ + targetApplied(nodeId: number, args: AppliedArgs): TransitionResult { + const app = this.requireApp(args.applicationId); + if (app.target_mode !== 'direct') { + throw new GitOpsTransitionError('target application is not direct'); + } + if (app.accepted_generation_id !== args.generationId) { + throw new GitOpsTransitionError('generation is not accepted'); + } + // The application's accepted_generation_id does not move again until a + // later sourceAccepted call, so a delayed dispatch for a superseded-but- + // still-accepted generation would otherwise pass the check above even + // after a newer candidate has already been staged for this target. Only + // an acceptance reference this application actually recorded may bind a + // target; a caller passing any other id would otherwise write + // unverifiable authorization evidence straight onto the target row. + if (app.source_acceptance_ref !== args.sourceAcceptanceId) { + throw new GitOpsTransitionError('source acceptance reference does not match the accepted generation'); + } + return this.mutateTarget(args.applicationId, nodeId, args.envelope, 'target_applied', args.generationId, (target) => { + if (target.target_status !== 'active') { + throw new GitOpsTransitionError('cannot apply to a tombstoned target'); + } + if (target.candidate_generation_id !== args.generationId) { + throw new GitOpsTransitionError('target candidate does not match applied generation'); + } + const before = { appliedGenerationId: target.applied_generation_id }; + this.applyTargetAcceptanceMutation(target, args); + return { before, after: { appliedGenerationId: args.generationId } }; + }); + } + /** * The single transaction that makes a create-from-Git durable. * @@ -841,7 +884,7 @@ export class GitOpsTransitions { this.clearActive(app); } app.suspended_at = envelope.at; - app.pause_reason = reason; + app.source_suspended_reason = reason; }); } @@ -850,10 +893,106 @@ export class GitOpsTransitions { return this.mutateApp(applicationId, envelope, 'source_unsuspended', 'committed', (app) => { if (!app.suspended_at) throw new GitOpsTransitionError('source is not suspended'); app.suspended_at = null; - app.pause_reason = null; + app.source_suspended_reason = null; }); } + /** + * Reserve a durable attempt before any side effect: a bare history + * insert in its own transaction, deliberately not through mutateApp, so + * nothing about the application row changes. A `reserved: false` return + * means this exact (application, operation) already reserved -- the + * caller reconstructs from durable state rather than repeating work. + * + * `followerOf` records that this reservation joined another running + * attempt. Recovery uses the link to settle the follower from its leader's + * result. `deliveryIntent` preserves the original webhook apply and deploy + * decision so redelivery cannot change behavior with later settings. + */ + reserveReconcileAttempt( + applicationId: string, + envelope: EventEnvelope, + followerOf?: string, + deliveryIntent?: ReconcileDeliveryIntent, + ): { reserved: boolean } { + return this.raw().transaction(() => ({ + reserved: this.insertReconcileReservation(this.requireApp(applicationId), envelope, followerOf, deliveryIntent), + }))(); + } + + /** + * Allocate the next attemptSeq for a submission with no stable external + * delivery identity, and reserve its durable attempt in the same + * transaction, so two concurrent submissions can never mint the same + * operation id. Unlike reserveReconcileAttempt, this does write one + * column of application state (attempt_seq) -- allocation is the one + * thing here that is not a bare history insert, since a fresh id has to + * come from somewhere durable. Only the allocated id's uniqueness is + * load-bearing; its embedded sequence number is for traceability. + */ + allocateReconcileAttempt( + applicationId: string, + actor: string | null, + trigger: string, + at: number, + followerOf?: string, + ): { operationId: string; reserved: boolean } { + return this.raw().transaction(() => { + const app = this.requireApp(applicationId); + const seq = app.attempt_seq + 1; + this.raw().prepare('UPDATE gitops_applications SET attempt_seq = ? WHERE id = ?').run(seq, applicationId); + const operationId = `${applicationId}:attempt:${seq}`; + const envelope: EventEnvelope = { operationId, actor, trigger, at }; + return { operationId, reserved: this.insertReconcileReservation(app, envelope, followerOf) }; + })(); + } + + /** + * The reservation row itself, shared by reserveReconcileAttempt and + * allocateReconcileAttempt: a bare history insert whose dedupe index is + * what makes a repeat reservation report false rather than recording a + * second attempt. + */ + private insertReconcileReservation( + app: GitOpsApplicationRow, + envelope: EventEnvelope, + followerOf: string | undefined, + deliveryIntent?: ReconcileDeliveryIntent, + ): boolean { + return this.history(app, envelope, { + stage: 'source_reconcile_started', + outcome: 'committed', + before: {}, + after: { + ...(followerOf ? { followerOf } : {}), + ...(deliveryIntent ? { deliveryIntent } : {}), + }, + }) !== null; + } + + /** + * Settle a reserved attempt with its normalized outcome, the same way: + * a bare history insert, not a state mutation. Safe to call more than + * once for the same operation; a repeat is a no-op via the history + * dedupe index, so the first settled result is never overwritten. + */ + settleReconcileAttempt( + applicationId: string, + envelope: EventEnvelope, + result: { outcome: string; reason: string; nextAction: string; retryAt?: number; commitSha?: string }, + ): { settled: boolean } { + return this.raw().transaction(() => { + const app = this.requireApp(applicationId); + const historyId = this.history(app, envelope, { + stage: 'source_reconcile_settled', + outcome: 'committed', + before: {}, + after: { ...result }, + }); + return { settled: historyId !== null }; + })(); + } + /** * Pause a rollout, application-wide or on one target. * @@ -1893,7 +2032,8 @@ export class GitOpsTransitions { * Guard every precondition of `applied` and return the active targets the * acceptance has to bind. */ - private acceptanceTargets(app: GitOpsApplicationRow, args: AppliedArgs): GitOpsTargetCurrentRow[] { + /** Guard every application-level precondition of accepting a candidate. */ + private requireAcceptableCandidate(app: GitOpsApplicationRow, args: AppliedArgs): void { if (app.candidate_generation_id !== args.generationId) { throw new GitOpsTransitionError('applied generation is not the current candidate'); } @@ -1913,6 +2053,10 @@ export class GitOpsTransitions { throw new GitOpsTransitionError('live apply belongs to a different operation'); } } + } + + private acceptanceTargets(app: GitOpsApplicationRow, args: AppliedArgs): GitOpsTargetCurrentRow[] { + this.requireAcceptableCandidate(app, args); const targets = this.store().listTargets(app.id).filter((row) => row.target_status === 'active'); if (app.target_mode === 'direct') { for (const target of targets) { @@ -1924,6 +2068,34 @@ export class GitOpsTransitions { return targets; } + /** The mode-neutral application-row mutation `applied` and `sourceAccepted` share. */ + private applySourceAcceptanceMutation(app: GitOpsApplicationRow, args: AppliedArgs): void { + this.insertAcceptanceRecords(app, args); + app.accepted_generation_id = args.generationId; + app.artifact_set_id = args.artifactSetId; + app.latest_artifact_set_id = args.artifactSetId; + app.source_acceptance_ref = args.sourceAcceptanceId; + app.candidate_generation_id = null; + app.candidate_plan_blocked = 0; + app.review_required = 0; + if (args.activateCreating && app.lifecycle_status === 'creating') { + app.lifecycle_status = 'active'; + } + this.clearActive(app); + this.clearAppFailure(app, ['apply', 'fetch', 'validation']); + this.clearInterruption(app, 'apply_started'); + } + + /** The Direct-only target-row mutation `applied` and `targetApplied` share. */ + private applyTargetAcceptanceMutation(target: GitOpsTargetCurrentRow, args: AppliedArgs): void { + target.desired_generation_id = args.generationId; + target.applied_generation_id = args.generationId; + target.expected_artifact_set_id = args.artifactSetId; + target.latest_artifact_set_id = args.artifactSetId; + target.source_acceptance_ref = args.sourceAcceptanceId; + target.candidate_generation_id = null; + } + /** Seed the unresolved artifact row and the source acceptance this apply proves. */ private insertAcceptanceRecords(app: GitOpsApplicationRow, args: AppliedArgs): void { const artifact: GitOpsArtifactSetRow = { @@ -2247,7 +2419,8 @@ export class GitOpsTransitions { legacy_combined_approval_ref=?, preflight_fingerprint=?, latest_operation_id=?, active_operation_id=?, active_operation_stage=?, active_operation_at=?, active_generation_id=?, - pause_at=?, pause_reason=?, partial_json=?, + pause_at=?, pause_reason=?, source_suspended_reason=?, + source_policy=?, poll_interval_secs=?, next_poll_at=?, attempt_seq=?, partial_json=?, failure_stage=?, failure_class=?, failure_at=?, retry_at=?, retry_count=?, suspended_at=?, recovery_ref=?, recovery_phase=?, interruption_stage=?, interruption_at=?, interruption_operation_id=?, @@ -2264,7 +2437,8 @@ export class GitOpsTransitions { app.legacy_combined_approval_ref, app.preflight_fingerprint, app.latest_operation_id, app.active_operation_id, app.active_operation_stage, app.active_operation_at, app.active_generation_id, - app.pause_at, app.pause_reason, app.partial_json, + app.pause_at, app.pause_reason, app.source_suspended_reason, + app.source_policy, app.poll_interval_secs, app.next_poll_at, app.attempt_seq, app.partial_json, app.failure_stage, app.failure_class, app.failure_at, app.retry_at, app.retry_count, app.suspended_at, app.recovery_ref, app.recovery_phase, app.interruption_stage, app.interruption_at, app.interruption_operation_id, diff --git a/backend/src/services/gitops/triggers.ts b/backend/src/services/gitops/triggers.ts new file mode 100644 index 00000000..16c7c512 --- /dev/null +++ b/backend/src/services/gitops/triggers.ts @@ -0,0 +1,77 @@ +/** + * Normalized reconciliation triggers for the GitOps source controller. + * + * A trigger only authorizes evaluation; it is not proof anything changed. + * `manual`, `webhook`, `poll`, and `retry` have current execution producers. + * The remaining values are typed ahead of later deliveries so those callers + * extend this union instead of inventing a parallel one. + */ +export type ReconcileTrigger = + | 'manual' + | 'api' + | 'webhook' + | 'poll' + | 'retry' + | 'config_change' + | 'startup' + | 'resume' + | 'provider_event' + | 'schedule' + | 'binding_change'; + +/** + * One normalized submission to the controller. `dismiss` is deliberately + * not a reconcile intent: it changes candidate state but does not + * authorize source evaluation. + */ +export type ReconcileRequest = + | { + intent: 'fetch'; + applicationId: string; + stackName: string; + trigger: ReconcileTrigger; + actor: string; + deliveryId?: string; + } + | { + intent: 'apply'; + applicationId: string; + stackName: string; + trigger: ReconcileTrigger; + actor: string; + commitSha: string; + planFingerprint: string; + deploy: boolean; + deliveryId?: string; + }; + +/** + * The in-process joining key for concurrent evaluations of the same work. + * A fetch has only one live outcome per application regardless of trigger, + * so any two fetch submissions for the same application and stack join. An + * apply is identified by exactly what it would do: two applies join only + * when they target the same commit, the same plan fingerprint, and the + * same deploy choice. Two applies that differ in any of those must never + * join, or one request could silently receive another request's result. + * + * Both the fetch and the apply form carry the stack name alongside the + * applicationId, so a caller that pairs a live applicationId with the + * wrong stackName can never join a leader evaluating the right one. + */ +export function coalesceKey(request: ReconcileRequest): string { + if (request.intent === 'fetch') { + return `${request.applicationId}:${request.stackName}:fetch`; + } + return `${request.applicationId}:${request.stackName}:apply:${request.commitSha}:${request.planFingerprint}:${request.deploy}`; +} + +/** + * A producer-namespaced key for an external delivery, so the same delivery + * id from two different trigger sources is never treated as one delivery. + * Also namespaced by intent: a webhook that both fetches and applies under + * one delivery id must reserve two distinct attempts, not have the apply's + * reservation collide with the fetch's and silently never run. + */ +export function deliveryKey(trigger: ReconcileTrigger, intent: ReconcileRequest['intent'], deliveryId: string): string { + return `${trigger}:${intent}:${deliveryId}`; +} diff --git a/backend/src/services/gitops/types.ts b/backend/src/services/gitops/types.ts index 052d6a0e..3d3790dc 100644 --- a/backend/src/services/gitops/types.ts +++ b/backend/src/services/gitops/types.ts @@ -71,6 +71,13 @@ export type GitOpsApplicationRow = { active_generation_id: string | null; pause_at: number | null; pause_reason: string | null; + /** sourceSuspended/sourceUnsuspended's own reason field; independent of pause_reason. */ + source_suspended_reason: string | null; + /** Controller-owned. See gitops/SourceController.ts. */ + source_policy: 'manual' | 'review' | 'automatic'; + poll_interval_secs: number | null; + next_poll_at: number | null; + attempt_seq: number; partial_json: string | null; failure_stage: ApplicationFailureStage | null; failure_class: string | null; @@ -123,6 +130,7 @@ export type GitOpsCreateCheckpointRow = { encrypted_deploy_key: string | null; ssh_known_hosts_entry: string | null; ssh_host_key_fingerprint: string | null; + encrypted_ca_bundle: string | null; auto_apply_on_webhook: number; auto_deploy_on_apply: number; commit_sha: string | null; @@ -158,6 +166,13 @@ export type GitOpsGenerationRow = { actor: string | null; previous_generation_id: string | null; redacted_limitations_json: string; + /** Portable accepted-generation contract fields. See gitops/handoff.ts. */ + portable_manifest_json: string | null; + compose_inputs_json: string | null; + source_policy_evidence_json: string | null; + security_policy_evidence_json: string | null; + support_requirements_json: string | null; + compatibility_requirements_json: string | null; created_at: number; }; @@ -401,7 +416,7 @@ export type SourceFacet = | (SourceIdentityFields & { status: 'source_superseded'; supersededGenerationId: string }) | (SourceIdentityFields & { status: 'applying'; activeOperationId: string; activeGenerationId: string }) | (SourceIdentityFields & { status: 'source_retry_scheduled'; retryAt: number; retryCount: number }) - | (SourceIdentityFields & { status: 'source_suspended'; suspendedAt: number }) + | (SourceIdentityFields & { status: 'source_suspended'; suspendedAt: number; suspendedReason: string | null }) | (SourceIdentityFields & { status: 'source_failed'; failureStage: 'fetch' | 'validation' | 'apply' | 'create'; diff --git a/backend/src/services/selfDevBuildDetect.ts b/backend/src/services/selfDevBuildDetect.ts index 65cf4cfa..2ee64ef6 100644 --- a/backend/src/services/selfDevBuildDetect.ts +++ b/backend/src/services/selfDevBuildDetect.ts @@ -27,13 +27,17 @@ export type SelfDevBuildDetectResult = | { kind: 'inconclusive'; reason: string }; /** The subset of `docker image inspect` output the detector reads. */ -interface InspectedImage { +export interface InspectedImage { RepoDigests: string[]; Os: string; Architecture: string; } -async function defaultInspectImage(imageId: string): Promise { +/** Bounded read of `docker image inspect` (RepoDigests, OS, architecture) for a + * resolved image. Reused by `SelfIdentityService` for revision enrichment; + * callers wrap it so any rejection stays isolated from the fields already + * captured. */ +export async function defaultInspectImage(imageId: string): Promise { const inspect = await DockerController.getInstance().getDocker().getImage(`sha256:${imageId}`).inspect(); return { RepoDigests: inspect.RepoDigests ?? [], Os: inspect.Os, Architecture: inspect.Architecture }; } diff --git a/backend/src/utils/gitSourceHttp.ts b/backend/src/utils/gitSourceHttp.ts index b7d6fe8f..f32f15fa 100644 --- a/backend/src/utils/gitSourceHttp.ts +++ b/backend/src/utils/gitSourceHttp.ts @@ -34,10 +34,20 @@ export function gitSourceStatus(code: GitSourceErrorCode): number { case 'PLAN_UNAVAILABLE': case 'OPERATION_IN_FLIGHT': return 409; + case 'RATE_LIMITED': + return 429; case 'NETWORK_TIMEOUT': return 504; - default: + case 'GIT_ERROR': return 400; + default: { + // Exhaustiveness guard: if a new code is added to the union without a + // case here, this becomes a compile error instead of silently mapping + // to 400. + const _exhaustive: never = code; + void _exhaustive; + return 400; + } } } diff --git a/backend/src/utils/outboundTarget.ts b/backend/src/utils/outboundTarget.ts new file mode 100644 index 00000000..45d63352 --- /dev/null +++ b/backend/src/utils/outboundTarget.ts @@ -0,0 +1,211 @@ +import dns, { promises as dnsPromises, type LookupAddress, type LookupAllOptions } from 'dns'; +import http from 'http'; +import https from 'https'; +import net, { type LookupFunction } from 'net'; +import { Agent as UndiciAgent, fetch as undiciFetch, type RequestInfo, type RequestInit, type Response } from 'undici'; + +const blockedIpv4 = new net.BlockList(); +blockedIpv4.addSubnet('0.0.0.0', 8, 'ipv4'); +blockedIpv4.addSubnet('127.0.0.0', 8, 'ipv4'); +blockedIpv4.addSubnet('169.254.0.0', 16, 'ipv4'); +blockedIpv4.addSubnet('192.0.0.0', 24, 'ipv4'); +blockedIpv4.addSubnet('192.0.2.0', 24, 'ipv4'); +blockedIpv4.addSubnet('192.88.99.0', 24, 'ipv4'); +blockedIpv4.addSubnet('198.18.0.0', 15, 'ipv4'); +blockedIpv4.addSubnet('198.51.100.0', 24, 'ipv4'); +blockedIpv4.addSubnet('203.0.113.0', 24, 'ipv4'); +blockedIpv4.addSubnet('224.0.0.0', 4, 'ipv4'); +blockedIpv4.addSubnet('240.0.0.0', 4, 'ipv4'); +blockedIpv4.addAddress('100.100.100.200', 'ipv4'); + +const blockedIpv6 = new net.BlockList(); +blockedIpv6.addAddress('::', 'ipv6'); +blockedIpv6.addAddress('::1', 'ipv6'); +blockedIpv6.addSubnet('100::', 64, 'ipv6'); +blockedIpv6.addSubnet('2001:db8::', 32, 'ipv6'); +blockedIpv6.addSubnet('fe80::', 10, 'ipv6'); +blockedIpv6.addSubnet('ff00::', 8, 'ipv6'); +blockedIpv6.addAddress('fd00:ec2::254', 'ipv6'); + +const loopbackIpv4 = new net.BlockList(); +loopbackIpv4.addSubnet('127.0.0.0', 8, 'ipv4'); + +export class UnsafeOutboundTargetError extends Error { + public readonly reason: 'blocked' | 'unresolved'; + public readonly code = 'EACCES'; + + public constructor(reason: 'blocked' | 'unresolved') { + super(reason === 'blocked' + ? 'The target address is not allowed.' + : 'The target host could not be resolved.'); + this.name = 'UnsafeOutboundTargetError'; + this.reason = reason; + } +} + +export function isBlockedOutboundAddress(address: string): boolean { + const family = net.isIP(address); + if (family === 4) return blockedIpv4.check(address, 'ipv4'); + if (family === 6) { + const mappedIpv4 = ipv4FromMappedIpv6(address); + return mappedIpv4 + ? blockedIpv4.check(mappedIpv4, 'ipv4') + : blockedIpv6.check(address, 'ipv6'); + } + return true; +} + +function isE2eLoopbackAllowed(address: string): boolean { + if (process.env.NODE_ENV !== 'test' || process.env.SENCHO_E2E_ALLOW_LOOPBACK_OUTBOUND !== 'true') { + return false; + } + if (net.isIPv4(address)) return loopbackIpv4.check(address, 'ipv4'); + if (!net.isIPv6(address)) return false; + const mappedIpv4 = ipv4FromMappedIpv6(address); + return mappedIpv4 + ? loopbackIpv4.check(mappedIpv4, 'ipv4') + : address === '::1'; +} + +function isDisallowedOutboundAddress(address: string): boolean { + return isBlockedOutboundAddress(address) && !isE2eLoopbackAllowed(address); +} + +function ipv4FromMappedIpv6(address: string): string | null { + const mapped = address.match(/^(?:::ffff:|0:0:0:0:0:ffff:)(.+)$/i)?.[1]; + if (!mapped) return null; + if (net.isIPv4(mapped)) return mapped; + const words = mapped.split(':'); + if (words.length !== 2 || words.some((word) => !/^[0-9a-f]{1,4}$/i.test(word))) return null; + const high = Number.parseInt(words[0], 16); + const low = Number.parseInt(words[1], 16); + return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; +} + +function lookupHostname(url: URL): string { + return url.hostname.startsWith('[') && url.hostname.endsWith(']') + ? url.hostname.slice(1, -1) + : url.hostname; +} + +export async function assertSafeOutboundHostname(hostname: string): Promise { + await resolveSafeOutboundHostname(hostname); +} + +type ResolveAllAddresses = (hostname: string) => Promise; +type ResolvedOutboundAddresses = [LookupAddress, ...LookupAddress[]]; + +const systemResolveAllAddresses: ResolveAllAddresses = (hostname) => + dnsPromises.lookup(hostname, { all: true, verbatim: true }); + +export async function resolveSafeOutboundHostname( + hostname: string, + resolveAllAddresses: ResolveAllAddresses = systemResolveAllAddresses, +): Promise { + const normalizedHostname = hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; + if (net.isIP(normalizedHostname) !== 0) { + if (isDisallowedOutboundAddress(normalizedHostname)) throw new UnsafeOutboundTargetError('blocked'); + return [{ address: normalizedHostname, family: net.isIPv4(normalizedHostname) ? 4 : 6 }]; + } + + let addresses: LookupAddress[]; + try { + addresses = await resolveAllAddresses(normalizedHostname); + } catch { + throw new UnsafeOutboundTargetError('unresolved'); + } + const [first, ...rest] = addresses; + if (!first) throw new UnsafeOutboundTargetError('unresolved'); + if (addresses.some(({ address }) => isDisallowedOutboundAddress(address))) { + throw new UnsafeOutboundTargetError('blocked'); + } + return [first, ...rest]; +} + +type LookupAllAddresses = ( + hostname: string, + options: LookupAllOptions, + callback: (error: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void, +) => void; + +const systemLookupAllAddresses: LookupAllAddresses = (hostname, options, callback) => { + dns.lookup(hostname, options, callback); +}; + +export function createSafeOutboundLookup(lookupAllAddresses: LookupAllAddresses): LookupFunction { + return (hostname, options, callback): void => lookupAllAddresses(hostname, { ...options, all: true }, (error, addresses) => { + if (error) { + callback(error, '', 0); + return; + } + if (!Array.isArray(addresses) || addresses.length === 0) { + callback(new UnsafeOutboundTargetError('unresolved'), '', 0); + return; + } + if (addresses.some(({ address }) => isDisallowedOutboundAddress(address))) { + callback(new UnsafeOutboundTargetError('blocked'), '', 0); + return; + } + if (options.all) { + callback(null, addresses); + return; + } + callback(null, addresses[0].address, addresses[0].family); + }); +} + +export const safeOutboundLookup = createSafeOutboundLookup(systemLookupAllAddresses); + +export const safeHttpAgent = new http.Agent({ lookup: safeOutboundLookup }); +export const safeHttpsAgent = new https.Agent({ lookup: safeOutboundLookup }); +const safeFetchDispatcher = new UndiciAgent({ connect: { lookup: safeOutboundLookup } }); + +export function safeAxiosTransport(trustedLoopback = false): { + maxRedirects: number; + proxy: false; + httpAgent?: http.Agent; + httpsAgent?: https.Agent; +} { + return { + maxRedirects: 0, + proxy: false, + ...(trustedLoopback ? {} : { httpAgent: safeHttpAgent, httpsAgent: safeHttpsAgent }), + }; +} + +export async function safeRemoteFetch( + input: RequestInfo, + init: RequestInit = {}, + trustedLoopback = false, +): Promise { + if (!trustedLoopback) { + const raw = input instanceof URL + ? input.toString() + : typeof input === 'string' ? input : input.url; + const host = lookupHostname(new URL(raw)); + if (net.isIP(host) !== 0 && isDisallowedOutboundAddress(host)) { + throw new UnsafeOutboundTargetError('blocked'); + } + } + try { + return await undiciFetch(input, { + ...init, + ...(trustedLoopback ? {} : { dispatcher: safeFetchDispatcher }), + redirect: 'error', + }); + } catch (error: unknown) { + const cause = error instanceof Error ? error.cause : undefined; + if (cause instanceof UnsafeOutboundTargetError) throw cause; + throw error; + } +} + +export async function assertSafeOutboundUrl( + raw: string, +): Promise { + const url = new URL(raw); + await assertSafeOutboundHostname(lookupHostname(url)); + return url; +} diff --git a/backend/src/utils/snapshot-capture.ts b/backend/src/utils/snapshot-capture.ts index a782b465..d7a057f7 100644 --- a/backend/src/utils/snapshot-capture.ts +++ b/backend/src/utils/snapshot-capture.ts @@ -9,6 +9,7 @@ import { FileSystemService } from '../services/FileSystemService'; import { NodeRegistry } from '../services/NodeRegistry'; import { formatNoTargetError } from './remoteTarget'; import { isDebugEnabled } from './debug'; +import { safeRemoteFetch } from './outboundTarget'; // Presence map over every operator-authored dossier field. Typing it as // Record makes the build fail if a field is @@ -215,10 +216,10 @@ export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = fa const headers: Record = {}; if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; - const stacksRes = await fetch(`${baseUrl}/api/stacks`, { + const stacksRes = await safeRemoteFetch(`${baseUrl}/api/stacks`, { headers, signal: AbortSignal.timeout(15000), - }); + }, target.trustedLoopback); if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node'); const stackNames = await stacksRes.json() as string[]; @@ -231,10 +232,10 @@ export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = fa let composeContent: string; try { - const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, { + const composeRes = await safeRemoteFetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, { headers, signal: AbortSignal.timeout(15000), - }); + }, target.trustedLoopback); if (!composeRes.ok) { const reason = `compose.yaml fetch failed (HTTP ${composeRes.status}); stack skipped`; console.warn(`[Fleet Snapshot] ${reason} ("${stackName}" on "${node.name}")`); @@ -255,10 +256,10 @@ export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = fa files.push({ filename: 'compose.yaml', content: composeContent }); try { - const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, { + const envRes = await safeRemoteFetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, { headers, signal: AbortSignal.timeout(15000), - }); + }, target.trustedLoopback); // The remote replies 200 with an empty body and X-Env-Exists: false when a // stack has no .env. Treat that as absent (matching the local ENOENT path) // so restore does not write a spurious empty .env. An older remote that @@ -280,10 +281,10 @@ export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = fa let dossier: StackDossierFields | undefined; if (captureDocs) { try { - const dossierRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/dossier`, { + const dossierRes = await safeRemoteFetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/dossier`, { headers, signal: AbortSignal.timeout(15000), - }); + }, target.trustedLoopback); if (dossierRes.ok) { const fields = pickDossierFields(await dossierRes.json() as Record); if (dossierHasContent(fields)) dossier = fields; diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index fce3cf17..d95bfbf9 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -1,5 +1,7 @@ import path from 'path'; +import net from 'net'; import { sanitizeForLog } from './safeLog'; +import { isBlockedOutboundAddress } from './outboundTarget'; /** * Stack name must only contain URL-safe characters with no path separators. @@ -33,12 +35,13 @@ export function isValidRemoteUrl( if (!['http:', 'https:'].includes(url.protocol)) { return { valid: false, reason: 'API URL must use http:// or https://' }; } - // Node.js URL API preserves brackets for IPv6: new URL('http://[::1]').hostname === '[::1]' - const loopback = /^(localhost|127(\.\d+){3}|\[::1\]|0\.0\.0\.0)$/i; - if (loopback.test(url.hostname)) { + const hostname = url.hostname.startsWith('[') && url.hostname.endsWith(']') + ? url.hostname.slice(1, -1) + : url.hostname; + if (hostname.toLowerCase() === 'localhost' || (net.isIP(hostname) !== 0 && isBlockedOutboundAddress(hostname))) { return { valid: false, - reason: 'API URL cannot point to localhost or loopback - use the actual host address', + reason: 'API URL target is not allowed', }; } return { valid: true, url }; diff --git a/backend/src/websocket/remoteForwarder.ts b/backend/src/websocket/remoteForwarder.ts index 37059c56..4854a674 100644 --- a/backend/src/websocket/remoteForwarder.ts +++ b/backend/src/websocket/remoteForwarder.ts @@ -6,6 +6,14 @@ import { wsProxyServer } from '../proxy/websocketProxy'; import { getErrorMessage } from '../utils/errors'; import { rejectUpgrade as reject } from './reject'; import { consoleSessionPathForPathname } from '../helpers/consoleSession'; +import type { ProxyTarget } from '../services/NodeRegistry'; +import { + assertSafeOutboundUrl, + safeHttpAgent, + safeHttpsAgent, + safeRemoteFetch, + UnsafeOutboundTargetError, +} from '../utils/outboundTarget'; /** * Forward a WebSocket upgrade to a remote Sencho instance. Handles the @@ -26,7 +34,7 @@ export async function handleRemoteForwarder( head: Buffer, opts: { pathname: string; - target: { apiUrl: string; apiToken: string }; + target: ProxyTarget; /** Hub browser operator; recorded as acting_as on the remote audit trail. */ actingAs?: string; }, @@ -35,7 +43,16 @@ export async function handleRemoteForwarder( if (!target.apiUrl) return reject(socket, 503, 'Service Unavailable'); const wsTarget = target.apiUrl.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws'); - const isPilotLoopback = target.apiToken === ''; + const isPilotLoopback = target.trustedLoopback; + if (!isPilotLoopback) { + try { + await assertSafeOutboundUrl(target.apiUrl); + } catch (error: unknown) { + const reason = error instanceof UnsafeOutboundTargetError ? error.reason : 'validation-error'; + console.error(`[WS Proxy] Refused remote target (${reason}):`, getErrorMessage(error, 'unknown')); + return reject(socket, 502, 'Bad Gateway'); + } + } // Interactive console paths (host console / container exec) are guarded on // the remote by an isProxyToken check that rejects the long-lived api_token. @@ -49,7 +66,7 @@ export async function handleRemoteForwarder( if (sessionPath && !isPilotLoopback) { try { const consoleHeaders = LicenseService.getInstance().getProxyHeaders(); - const tokenRes = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/system/console-token`, { + const tokenRes = await safeRemoteFetch(`${target.apiUrl.replace(/\/$/, '')}/api/system/console-token`, { method: 'POST', headers: { 'Authorization': `Bearer ${target.apiToken}`, @@ -98,5 +115,6 @@ export async function handleRemoteForwarder( const fwdUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); fwdUrl.searchParams.delete('nodeId'); req.url = fwdUrl.pathname + (fwdUrl.searchParams.toString() ? `?${fwdUrl.searchParams.toString()}` : ''); - wsProxyServer.ws(req, socket, head, { target: wsTarget }); + const agent = target.apiUrl.startsWith('https:') ? safeHttpsAgent : safeHttpAgent; + wsProxyServer.ws(req, socket, head, isPilotLoopback ? { target: wsTarget } : { target: wsTarget, agent }); } diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 11b3ba50..dc121ae7 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ // Build the baseline DB (schema + migrations + admin seed) once; each // test file's setupTestDb copies it instead of re-running migrations. globalSetup: ['./src/__tests__/helpers/vitestGlobalSetup.ts'], + setupFiles: ['./src/__tests__/helpers/allowLoopbackTargets.ts'], // Each test file gets its own worker so singletons are fresh between files. pool: 'forks', // Cap concurrency: each worker dynamic-imports the full Express stack diff --git a/docs/docs.json b/docs/docs.json index 287d3f24..3f2ecfe8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -135,6 +135,7 @@ "features/health-gated-updates", "features/deploy-enforcement", "features/git-sources", + "features/git-transport-support", "features/blueprint-model", "features/scheduled-operations", "features/auto-update-policies", diff --git a/docs/feature-catalog.yaml b/docs/feature-catalog.yaml new file mode 100644 index 00000000..068b63d5 --- /dev/null +++ b/docs/feature-catalog.yaml @@ -0,0 +1,374 @@ +# Canonical feature catalog — Sencho tier reconciliation +# Source of truth. No internal Linear IDs stored here. +# Internal planning provenance lives in Linear, not in committed files. +# Schema: id, name, tier (community|admiral|internal), availability (shipped|planned|internal), +# category, publicName, publicRoadmapKey, summary, description, limitation, +# featured (bool), homepageOrder (int when featured=true) +# Cross-field invariant: tier: internal iff availability: internal. + +version: '1' +schema: 'canonical-v1' + +entries: + # ===== Core Compose & Deployments (Community) ===== + - id: compose-editor + name: Compose editor and stack management + tier: community + availability: shipped + category: compose-deploy + publicName: Compose editor, templates, Git sources + publicRoadmapKey: compose-editor + summary: Manage, edit, and deploy Compose stacks from YAML or templates. + description: Monaco YAML editor with syntax validation, multi-file Compose from Git sources, drift detection for documentation, and ordered multi-file Compose flows. + limitation: '' + featured: true + homepageOrder: 1 + + - id: git-pull-preview + name: Git pull preview and apply flow + tier: community + availability: shipped + category: compose-deploy + publicName: Git pull preview and apply + publicRoadmapKey: git-pull-preview + summary: Preview and apply changes from Git source before deploying. + featured: false + + - id: atomic-deploy-rollback + name: Atomic deployments and rollback + tier: community + availability: shipped + category: compose-deploy + publicName: Atomic deploys + rollback + publicRoadmapKey: atomic-deploy-rollback + summary: Atomic updates with health-gated rollback support. + description: Standard and atomic deploy/update workflows, rollback support, health-gated updates, stalled update detection and recovery. + limitation: '' + featured: true + homepageOrder: 2 + + # ===== Fleet & Orchestration (Community) ===== + - id: multi-node-fleet + name: Multi-node fleet visibility + tier: community + availability: shipped + category: fleet-orchestration + publicName: Multi-node fleet visibility + publicRoadmapKey: multi-node-fleet + summary: Visibility into nodes, stacks, and containers across a fleet. + description: Multi-node support, proxy-connected nodes, pilot agent-connected nodes, node enrollment, node compatibility checks. + limitation: '' + featured: true + homepageOrder: 3 + + - id: fleet-sync-baseline + name: Fleet Sync (policy replication) + tier: community + availability: shipped + category: fleet-orchestration + publicName: Fleet Sync (policy replication) + publicRoadmapKey: fleet-sync-baseline + summary: Replicate security policies, suppressions, and acknowledgements across nodes. + limitation: 'Baseline Fleet Sync is Community; additional governance enforcement is Admiral-planned.' + featured: true + homepageOrder: 4 + + - id: blueprint-reconcile + name: Blueprints and drift reconciliation + tier: community + availability: shipped + category: fleet-orchestration + publicName: Blueprints + drift detection + reconciliation + publicRoadmapKey: blueprint-reconcile + summary: Declarative fleet state with drift detection, manual reconciliation, and label-based node targeting. + description: Blueprints, label-based targeting, drift detection, drift notification and correction, manual reconciliation with Apply Now, stateful deployment review. + limitation: '' + featured: false + + # ===== Security Foundations (Community) ===== + - id: rbac-five-role + name: Full built-in RBAC + tier: community + availability: shipped + category: security-foundation + publicName: Full built-in RBAC (Admin, Viewer, Deployer, Node Admin, Auditor) + publicRoadmapKey: rbac-five-role + summary: Five built-in roles with per-resource scoped assignments. + description: Admin, Viewer, Deployer, Node Admin, Auditor roles; per-resource scoped assignments; unlimited users. + limitation: '' + featured: true + homepageOrder: 5 + + - id: fleet-secrets + name: Fleet Secrets (encrypted env bundles) + tier: community + availability: shipped + category: security-foundation + publicName: Fleet Secrets (encrypted env bundles) + publicRoadmapKey: fleet-secrets + summary: Encrypted versioned environment bundles for fleet-wide secrets. + limitation: '' + featured: true + homepageOrder: 6 + + - id: deploy-enforcement + name: Deploy enforcement policies + tier: community + availability: shipped + category: security-foundation + publicName: Deploy enforcement policies (CVE gate) + publicRoadmapKey: deploy-enforcement + summary: Block deploys based on vulnerability scanning results. + featured: false + + # ===== Identity & Access (Community / Admiral split) ===== + - id: custom-oidc-ssenders + name: Custom OIDC and SSO presets + tier: community + availability: shipped + category: identity-access + publicName: Custom OIDC + Google/GitHub/Okta SSO + publicRoadmapKey: custom-oidc-sso + summary: Self-hosted identity with any OIDC IdP and one-click presets. + description: Custom OIDC SSO, Google, GitHub, Okta presets; 2FA/MFA; recovery codes. + limitation: '' + featured: true + homepageOrder: 7 + + - id: ldap-ad + name: LDAP / Active Directory + tier: admiral + availability: shipped + category: identity-access + publicName: LDAP / Active Directory + publicRoadmapKey: ldap-ad + summary: Enterprise identity integration for organizational assurance. + description: LDAP / Active Directory identity provider integration. + limitation: 'Requires Admiral (paid) license; handled by requireTierForSsoProvider in backend middleware.' + featured: true + homepageOrder: 8 + + # ===== Audit & Evidence (Community partial / Admiral full) ===== + - id: audit-log-14day + name: Recent activity log (14-day window) + tier: community + availability: shipped + category: identity-access + publicName: Recent audit window (14 days) + publicRoadmapKey: audit-log-14day + summary: Basic audit visibility for recent actions. + description: Basic audit log with 14-day retention; visible to Community users. + limitation: 'Full audit log with export, anomaly detection, and extended retention requires Admiral.' + featured: false + + - id: audit-log-full + name: Full audit log (export, anomaly detection, retention) + tier: admiral + availability: shipped + category: identity-access + publicName: Full audit log (export + anomaly detection) + publicRoadmapKey: audit-log-full + summary: Durable audit evidence with export, anomaly detection, and retention. + description: Full audit log with export, anomaly detection, retention policies; managed audit evidence. + limitation: 'Paid-only; requires Admiral license. Enforced by requirePaid in auditLog routes.' + featured: true + homepageOrder: 9 + + # ===== Security Scanning & Policy (Community core / Admiral planned) ===== + - id: scan-on-demand + name: On-demand vulnerability scanning + tier: community + availability: shipped + category: security-foundation + publicName: On-demand vulnerability scanning + publicRoadmapKey: scan-on-demand + summary: Scan stacks and nodes for CVEs with suppression and acknowledgment. + description: On-demand scanning, node-wide scanning, CVE suppressions, misconfiguration acknowledgements, deploy enforcement policies, SARIF export, scan policy packs. + limitation: '' + featured: true + homepageOrder: 10 + + - id: change-review-planned + name: Compose Change Review + tier: admiral + availability: planned + category: security-foundation + publicName: Change Review (planned) + publicRoadmapKey: change-review-planned + summary: Required approval workflow for Compose changes before deployment. + description: Governance layer requiring approvals before deploy. + limitation: 'Approved roadmap; not yet shipped. Explicit Planned label in all public surfaces.' + featured: true + homepageOrder: 11 + + # ===== Fleet Operations (Community core / Admiral planned) ===== + - id: fleet-actions-bulk + name: Fleet Actions and bulk operations + tier: community + availability: shipped + category: fleet-orchestration + publicName: Fleet Actions + bulk operations + publicRoadmapKey: fleet-actions-bulk + summary: Bulk stack lifecycle actions across nodes. + description: Bulk actions on stacks, labels, fleet-wide schedules. + limitation: '' + featured: true + homepageOrder: 12 + + - id: protected-stacks-planned + name: Protected Stacks / Nodes + tier: admiral + availability: planned + category: fleet-orchestration + publicName: Protected stacks / nodes (planned) + publicRoadmapKey: protected-stacks-planned + summary: Protected stacks and nodes with maintenance windows and break-glass. + description: Protected workflows, maintenance windows, freezes, break-glass. + limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.' + featured: true + homepageOrder: 13 + + - id: fleet-readiness-planned + name: Fleet Readiness Score + tier: admiral + availability: planned + category: fleet-orchestration + publicName: Fleet Readiness Score (planned) + publicRoadmapKey: fleet-readiness-planned + summary: Organizational readiness score for fleet health and compliance. + limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.' + featured: false + + # ===== Governance & Policy (Community core / Admiral planned) ===== + - id: api-tokens + name: API tokens + tier: community + availability: shipped + category: identity-access + publicName: API tokens for CI/CD + publicRoadmapKey: api-tokens + summary: Long-lived machine credentials for automation. + limitation: '' + featured: true + homepageOrder: 14 + + - id: policy-pack-planned + name: Policy Pack Assignment + tier: admiral + availability: planned + category: governance + publicName: Policy pack assignment (planned) + publicRoadmapKey: policy-pack-planned + summary: Organization-wide policy pack enforcement with governed exceptions. + limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.' + featured: false + + # ===== Recovery (Community basic / Admiral managed) ===== + - id: manual-snapshots + name: Manual fleet snapshots + tier: community + availability: shipped + category: recovery + publicName: Manual fleet snapshots + publicRoadmapKey: manual-snapshots + summary: User-initiated fleet-wide backups. + limitation: '' + featured: false + + - id: recovery-vault + name: Recovery Vault (managed off-site snapshots) + tier: admiral + availability: shipped + category: recovery + publicName: Recovery Vault (managed off-site) + publicRoadmapKey: recovery-vault + summary: Managed recovery service with durable off-site storage. + description: Recovery vault with managed storage, verification, monitoring, retention, and restore-readiness evidence. + limitation: 'Requires Admiral; managed continuity pillar.' + featured: true + homepageOrder: 15 + + # ===== Business Assurance (Admiral only) ===== + - id: hardened-build + name: Hardened Build image channel + tier: admiral + availability: shipped + category: assurance + publicName: Hardened Build image channel + publicRoadmapKey: hardened-build + summary: Supported release channel with defined supply chain and support commitment. + limitation: 'Admiral entitlement; never a headline reason to purchase.' + featured: true + homepageOrder: 16 + + - id: priority-support + name: Priority Studio Saelix support + tier: admiral + availability: shipped + category: assurance + publicName: Priority email support + publicRoadmapKey: priority-support + summary: Accountable support commitment for Admiral customers. + limitation: 'Requires Admiral.' + featured: false + + - id: incident-timeline-planned + name: Incident Timeline + tier: admiral + availability: planned + category: assurance + publicName: Incident Timeline (planned) + publicRoadmapKey: incident-timeline-planned + summary: Durable incident timeline and service context. + limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.' + featured: false + + # ===== Registration & Registry (Community core / Admiral ECR temporary) ===== + - id: docker-hub-ghcr-custom + name: Local registry credentials (Docker Hub, GHCR, custom) + tier: community + availability: shipped + category: security-foundation + publicName: Local registry credentials + publicRoadmapKey: docker-hub-ghcr-custom + summary: Store and manage registry authentication. + limitation: '' + featured: false + + - id: ecr-admiral-temporary + name: AWS ECR registry credentials + tier: admiral + availability: shipped + category: security-foundation + publicName: AWS ECR registry credentials (temporary availability) + publicRoadmapKey: ecr-admiral-temporary + summary: AWS Elastic Container Registry authentication. + description: Temporary Admiral access to AWS ECR registry credentials; not positioned as core value. + limitation: 'Temporary availability; must not be positioned as core Admiral value in public copy.' + featured: false + + # ===== Internal / Experimental (not public) ===== + - id: mesh-routing + name: Mesh / Routing + tier: internal + availability: internal + category: internal + publicName: Mesh / Routing (internal) + publicRoadmapKey: mesh-routing + summary: '' + description: Internal experimental feature; not public tier; separate graduation decision required. + limitation: 'Internal; excluded from all public matrices. Must remain hidden until graduation decision lands.' + featured: false + homepageOrder: 99 + - id: experimental-flag + name: SENCHO_EXPERIMENTAL + tier: internal + availability: internal + category: internal + publicName: 'SENCHO_EXPERIMENTAL (internal discovery gate)' + publicRoadmapKey: experimental-flag + summary: Internal feature discovery gate; not a tier. + description: Feature flag controlling internal feature visibility; must not be used as user-facing marketing. + limitation: 'Not a public tier; never advertised in public comparison or docs.' + featured: false + homepageOrder: 100 diff --git a/docs/features/appearance.mdx b/docs/features/appearance.mdx index 31fc1ab0..782c6f6c 100644 --- a/docs/features/appearance.mdx +++ b/docs/features/appearance.mdx @@ -96,11 +96,10 @@ The **Display** group holds layout and log-chip preferences for this browser: The **Navigation** group chooses how the desktop top bar presents page destinations. Phone navigation is unchanged. - **Navigation style** - - **Smart bar** (recommended default): keeps a short set of primary destinations visible and moves the rest into a grouped **More** menu. - - **Classic bar**: shows the full horizontal destination strip. Choosing Classic shows a callout that Classic bar will be removed soon; the preference is kept until then. - - **Compact launcher**: puts destinations in a left-side launcher menu and optionally pins up to seven **quick links** on the bar. -- **Top navigation labels** (Classic and Smart): shows text beside top navigation icons. Turn it off for an icon-only bar; destinations stay reachable by tooltip, accessible name, and the command palette. Phone layout always keeps labels. With labels off, **Top navigation alignment** places the icon-only bar left or centered. -- **Quick links** (Compact launcher): labeled pins after the launcher, with a trailing **+** that opens reachable unpinned destinations. Right-click a pin and choose Remove, or manage the full list under Appearance. Up to seven pins; recommended defaults start you with four. + - **Compact launcher** (recommended default): puts destinations in a left-side launcher menu and optionally pins up to eight **quick links** on the bar. + - **Smart bar**: keeps a short set of primary destinations visible and moves the rest into a grouped **More** menu. +- **Top navigation labels** (Smart bar only): shows text beside top navigation icons. Turn it off for an icon-only bar; destinations stay reachable by tooltip, accessible name, and the command palette. Phone layout always keeps labels. With labels off, **Top navigation alignment** places the icon-only bar left or centered. +- **Quick links** (Compact launcher): labeled pins after the launcher, with a trailing **+** that opens reachable unpinned destinations. Right-click a pin and choose Remove, or manage the full list under Appearance. Up to eight pins; recommended defaults start you with a reachable set of up to six. Deploy-progress behavior and the diff-preview-before-save step are stack workflow preferences, so they live in **Settings → Infrastructure → Stacks**, not here. diff --git a/docs/features/git-sources.mdx b/docs/features/git-sources.mdx index eca271d4..3434ed2d 100644 --- a/docs/features/git-sources.mdx +++ b/docs/features/git-sources.mdx @@ -9,6 +9,8 @@ Git Sources turn any stack into a GitOps target. Point Sencho at a repository an Git Sources are available on every tier, including Community. +For exactly which transports, ref types, authentication methods, and Git hosts are supported today, with the evidence behind each, see [Git Transport Support](/features/git-transport-support). + ## How it works 1. Open a stack and click the **Git Source** button in the editor toolbar. @@ -29,7 +31,7 @@ The panel groups four regions: - **Pending update banner.** Appears at the top whenever a fetched commit is staged, however it was fetched. Its heading is the source state, so it says whether the commit is ready to apply, waiting on review, or blocked by local conflicts. Click **Review** to re-fetch the incoming commit and open the change plan. - **Form fields.** Repository URL, ref, the ordered compose-file picker, an optional project directory, optional sibling `.env` sync, authentication toggle, and the apply behavior radio group. - **Last applied stat strip.** Shows the short SHA of the last commit Sencho applied to disk, the source state (see below), and the timestamp of the most recent successful save or pull. -- **Footer actions.** **Remove** disconnects the source by exporting the effective compose model into a single `compose.yaml` and removing auto-discovered override files; the remaining materialized files are kept. **Pull now** fetches the configured branch, tag, or commit's current revision; **Save** or **Update** persists form changes after a reachability check passes. +- **Footer actions.** **Remove** disconnects the source by exporting the effective compose model into a single `compose.yaml` and removing auto-discovered override files; the remaining materialized files are kept. **Pull now** fetches the configured branch, tag, or commit's current revision; **Save** or **Update** persists form changes after a reachability check passes, except removing a stored custom CA certificate, which always saves immediately so a certificate you no longer trust can always be taken out. ## Source state @@ -97,6 +99,8 @@ Tick **Deploy after create** to run `docker compose up -d` immediately after the | **Authentication** | **Public (no auth)** for public repos, **Personal Access Token** for private HTTPS repos, or **SSH deploy key** for private SSH repos | | **Apply behavior** | See the three modes below | +Private repository hosts on your LAN or VPN are supported. Sencho refuses repository targets that resolve to loopback, link-local, multicast, or selected special-use addresses. + Saving runs a reachability check against the repository. If the URL is wrong, the token is invalid, the ref does not exist, or a file is missing, Sencho surfaces the error inline and nothing is persisted. ### Multiple compose files @@ -207,6 +211,22 @@ Use an `ssh://` URL when the Git server listens on a nonstandard port (for examp Switching authentication back to **Public (no auth)** or to a token clears the stored deploy key and host-key trust. +### Private HTTPS with a custom CA + +Self-hosted Git servers often use TLS certificates signed by a private certificate authority. By default, Sencho trusts the system certificate store on the host running the fetch. When your git server uses a private CA, paste the CA certificate (PEM) in **Custom CA certificate** on the Git source form. + +Sencho combines your CA with the system trust anchors (it does not replace them), so public hosts such as GitHub continue to validate normally. The CA bundle is encrypted at rest and is never returned after save. + +Leave the field empty when the server uses a publicly trusted certificate. + +Removing a stored CA always saves, even if the server currently needs it to be reached: retiring a certificate you no longer trust should never be blocked by the unreachability that retiring it causes. If the server still needs a private CA afterward, the next pull reports a certificate trust error until you upload one again. + +### Redirects + +Some Git servers answer with a redirect, for example when a repository moves to a canonical path. Sencho follows a redirect that stays on the same server (same scheme, host, and port) and only changes the path, so a relocated repository keeps working without you editing the URL. + +A redirect that points at a different server is refused, and the pull reports that the host redirected elsewhere. Sencho does not contact that other server or send it your token. If a repository has genuinely moved to a new host, update the repository URL on the Git source to the new address. + ## Local edits vs Git Sencho classifies every managed path against the last applied generation and the live disk. diff --git a/docs/features/git-transport-support.mdx b/docs/features/git-transport-support.mdx new file mode 100644 index 00000000..7d07bb1e --- /dev/null +++ b/docs/features/git-transport-support.mdx @@ -0,0 +1,81 @@ +--- +title: Git Transport Support +sidebarTitle: Transport Support +description: Which transports, reference types, authentication methods, and Git hosts Git Sources supports, with the evidence behind each claim. +--- + + + Git Sources, and everything on this page, is available on every tier, including Community. + + +This page states exactly what Git Sources supports today: which transports, reference types, authentication methods, TLS trust modes, and Git hosts, and what happens where a combination is not supported or not yet verified. For how to configure a Git source, see [Git Sources](/features/git-sources). + +Every "Supported" row here is backed by a test that runs on every change to Sencho, or by a dated pass against a real instance of that host. A row marked "Not yet verified" is not a claim of failure: it means that specific combination has not been exercised yet, so it is not advertised as working. Nothing on this page is inferred from a related combination that behaved correctly. + + + +## Transports + +| Transport | Status | Notes | +| --- | --- | --- | +| HTTPS | Supported | Personal Access Token for private repositories, or no credential at all for public ones. TLS verification uses the system trust store by default, or a per-source custom CA when configured. | +| SSH | Supported | A read-only deploy key with strict host-key verification. Standard (22) and nonstandard ports are both supported. | + +## Reference types + +| Reference type | Status | Notes | +| --- | --- | --- | +| Branch | Supported | Tracks the head of a branch; each pull resolves and pins the exact commit. | +| Tag | Supported | Both annotated and lightweight tags resolve to their target commit. | +| Commit SHA | Supported | A full commit SHA is pinned directly; the Git host must advertise the commit on some branch or tag. | + +## Authentication + +| Method | Status | Notes | +| --- | --- | --- | +| Public (no auth) | Supported | For public repositories. | +| Personal Access Token | Supported | Stored encrypted at rest, never returned after save. | +| SSH deploy key | Supported | Stored encrypted at rest; the server host key is verified on every fetch. | + +## Git hosts + +| Host | HTTPS | SSH | Branch | Tag | Commit SHA | Evidence | +| --- | --- | --- | --- | --- | --- | --- | +| Generic (self-hosted or any Git server) | Supported | Supported | Supported | Supported | Supported | Automated, every change | +| GitHub | Supported | Not yet verified | Supported | Not yet verified | Supported | Live, 2026-09-01 | +| GitLab | Supported | Not yet verified | Supported | Supported | Supported | Live, 2026-09-01 | +| Gitea | Supported | Supported | Supported | Supported | Supported | Live, 2026-09-01 | +| Forgejo | Supported | Supported | Supported | Supported | Supported | Live, 2026-09-01 | +| Bitbucket | Supported | Not yet verified | Supported | Not yet verified | Supported | Live, 2026-09-01 | + +## TLS and certificate authorities + +| Mode | Status | Notes | +| --- | --- | --- | +| System trust (default) | Supported | The host running the fetch trusts its system certificate store. | +| Per-source custom CA | Supported | Combined with the system trust anchors, so public hosts keep validating normally. Redirects are re-resolved and only followed when they stay on the source's own host. | + +## Not supported + +- **No Git LFS.** Compose and env files tracked via Git LFS are rejected rather than silently fetched as pointer stubs. Commit plain files instead. +- **No submodules.** Submodule contents are not fetched. Inputs and build contexts that reference submodule contents are refused with an actionable message; a warning is shown when .gitmodules is present. +- **No sparse or partial clone.** Every fetch materializes the complete repository at the resolved commit (shallow, single-branch); there is no sparse or partial clone for large monorepos. +- **No GitHub App authentication.** Authentication is Personal Access Token or SSH deploy key only. GitHub App installation tokens are not supported. +- **No provider pull/merge request revisions.** Sources track a branch, a tag, or a pinned commit SHA. A provider-specific pull request or merge request revision (for example GitHub's refs/pull/N/head) is not a supported ref shape. +- **Outbound target restrictions.** Repository targets that resolve to loopback, link-local, multicast, or selected special-use addresses are refused before any request is sent. Private hosts on an operator's own LAN or VPN are not affected by this restriction. +- **Branch/tag name collisions.** A bare ref name resolves as a branch first, then as a tag. If an operator renames a branch and a tag of the same name later appears, the source silently starts resolving the tag instead. A ref name that is also a valid 40 or 64 character hex string resolves as a commit SHA before either lookup. + + + +## How these claims are verified + +Two kinds of evidence back the rows above: + +- **Automated.** A real `git` client, talking to a real local test server over HTTPS or SSH, drives the exact same code path Sencho uses in production. These tests run on every change, so a regression here fails the build before it reaches a release. +- **Live.** A dated pass against a real instance of the named host (a public GitHub, GitLab, Gitea, Forgejo, or Bitbucket repository, or a self-hosted instance under Sencho's control). This is repeated periodically, not on every change, so its date tells you how current the result is. + +A host that is not listed, or a combination marked "Not yet verified," most likely still works: Git Sources speaks the standard Git smart-HTTP and SSH protocols, not anything host-specific. It simply has not been exercised as its own row yet. + + + Configure a repository, review pull previews, and read the field-by-field reference for every setting mentioned above. + diff --git a/docs/features/global-search.mdx b/docs/features/global-search.mdx index d4b725af..30eec240 100644 --- a/docs/features/global-search.mdx +++ b/docs/features/global-search.mdx @@ -3,7 +3,7 @@ title: Global Search description: Jump to any page, node, or stack from anywhere in the app with a single keystroke. --- -The **global search palette** lets you move around Sencho without reaching for the mouse. It covers the reachable page destinations for your tier and role (the same page list Classic top navigation, Smart primary and More, and mobile navigation use), every configured node, and every stack on every online node in your fleet. Compact launcher can also open **Settings** from its menu; Settings is not a palette page row. +The **global search palette** lets you move around Sencho without reaching for the mouse. It covers the reachable page destinations for your tier and role (the same page list top navigation and mobile navigation use), every configured node, and every stack on every online node in your fleet. Compact launcher can also open **Settings** from its menu; Settings is not a palette page row. Sencho global search palette open with no query over the blurred dashboard, the Pages group listing Home, Fleet, Resources, Networking, Security, and App Store each with a leading icon, with a scrollbar indicating more entries below. @@ -26,7 +26,7 @@ The palette groups results into three sections. | Group | What it contains | What happens when you pick one | |-------|------------------|--------------------------------| -| **Pages** | The reachable page destinations for your tier and role (the same set Classic / Smart / mobile navigation use). **Home**, **Resources**, **Networking**, **Security**, and **App Store** appear for signed-in operators; **Fleet** appears when your role holds the `node:read` permission; **Logs**, **Update**, **Schedules**, and **Console** appear for admins; **Audit** appears for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page | +| **Pages** | The reachable page destinations for your tier and role (the same set top navigation and mobile navigation use). **Home**, **Resources**, **Networking**, **Security**, and **App Store** appear for signed-in operators; **Fleet** appears when your role holds the `node:read` permission; **Logs**, **Update**, **Schedules**, and **Console** appear for admins; **Audit** appears for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page | | **Nodes** | Every node in your fleet, with a green dot for online and a grey dot for offline. The currently active node carries a small **ACTIVE** chip on the right. | Switches the active node without leaving the current page | | **Stacks** | Every compose stack on every online node, matched on the compose filename (extension included). | Switches to the stack's node and opens it in the editor | diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index fc0f82dc..67574fc3 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -108,7 +108,7 @@ The **Plan** card lists: - **License** (Community only): a link to view the AGPLv3 source on GitHub. - **Recovery Vault** (Admiral only): confirms the entitlement is included in your subscription. - **Hardened Build** (Admiral only): a **Switch to Hardened** button; see [Switching to Hardened Build](#switching-to-hardened-build) below. -- **Current image** and **Channel**: the image reference and channel (`Community` or `Hardened`) this control plane is currently running. +- **Current image** and **Channel**: the image reference and channel (`Community` or `Hardened`) this control plane is currently running. These rows reflect the running build, not the configured target, so a compose edit does not change them until the container is recreated. A hardened image is shown as `Restricted` to non-administrators. - **Customer**, **Product**, and **License key** (active licenses only): purchase metadata and your key masked to its last four characters (`****-****-****-XXXX`). The full key is never re-displayed after activation. ## Switching to Hardened Build diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index 3b4b719e..760c3e26 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -271,6 +271,8 @@ http://100.64.0.2:1852 ← Tailscale IP, encrypted by the VPN tunnel All traffic between nodes is encrypted by the VPN. Sencho does not need to do anything additional. +Remote node URLs may use private LAN, VPC, or VPN addresses. Sencho refuses targets that resolve to loopback, link-local, multicast, or selected special-use addresses. + #### Reverse proxy (Caddy, Nginx, Traefik) If you prefer TLS termination at each node, place a reverse proxy in front of each Sencho instance. [Caddy](https://caddyserver.com/) is the simplest option; it auto-provisions HTTPS certificates from Let's Encrypt with zero configuration: diff --git a/docs/getting-started/configuration.mdx b/docs/getting-started/configuration.mdx index ccb89064..d377e883 100644 --- a/docs/getting-started/configuration.mdx +++ b/docs/getting-started/configuration.mdx @@ -55,6 +55,7 @@ These tune optional subsystems. Most deployments never set them; the defaults ar | `GITSOURCE_MAX_PATH_DEPTH` | `64` | Maximum directory depth for a materialized repository path. Deeper paths are refused. | | `GITSOURCE_MAX_FILE_BYTES` | `10485760` | Maximum size of a single materialized file (10 MB). Oversized files are refused. | | `SENCHO_PUBLIC_URL` | *(request host)* | Set on the primary instance. Its externally reachable `http(s)://` URL, no trailing slash, baked into pilot enrollment so remote agents dial the public hostname rather than the address the admin used at setup. | +| `SENCHO_TRUSTED_PROXY_CIDRS` | *(unset)* | Comma-separated CIDRs of reverse proxy peers trusted to supply forwarded client addresses and schemes. Use `/32` for one IPv4 proxy, `/128` for one IPv6 proxy, or the proxy network CIDR. Unset or invalid values make Sencho ignore forwarding headers. | | `SENCHO_COMPOSE_COMMAND_TIMEOUT_MS` | `1800000` | Hard timeout for a single Compose command (pull, up, down) during deploy and update, in milliseconds (30 minutes). Sencho kills the command and reports failure if it runs longer than this, regardless of whether it is still producing output. Raise it only for very large images or slow storage. | | `SENCHO_COMPOSE_STALL_TIMEOUT_MS` | `600000` | Idle-output backstop for deploy and update Compose steps (pull and recreate), separate from the hard timeout above. If a step produces no output for this long while still running, Sencho stops it so a hung image pull surfaces a clear failure and the in-app recovery actions instead of spinning. Raise it on slow links or for heavy local image builds. | | `SENCHO_ZFS_ARCSTATS_PATH` | *(auto)* | Path **inside the container** to the OpenZFS ARC kstat file, for [ZFS ARC-aware host memory](#zfs-arc-aware-host-memory). Sencho checks this path first, then `/host/proc/spl/kstat/zfs/arcstats`, then `/proc/spl/kstat/zfs/arcstats`. Set it only when your ARC stats live at a non-standard path. | @@ -62,10 +63,6 @@ These tune optional subsystems. Most deployments never set them; the defaults ar Running a remote host as a pilot agent uses four more variables (`SENCHO_MODE`, `SENCHO_PRIMARY_URL`, `SENCHO_ENROLL_TOKEN`, and `SENCHO_PILOT_CA_FILE`), set only on the remote agent container. Sencho bakes them into the enrollment Compose file it generates, so you rarely write them by hand. See [Pilot Agent](/features/pilot-agent) for the full enrollment walkthrough. -| Variable | Default | Description | -|----------|---------|-------------| -| `SENCHO_TRUSTED_PROXY_CIDRS` | *(unset)* | Comma-separated CIDRs of reverse proxies that may set `X-Forwarded-Proto` for Pilot Agent TLS termination. When unset or invalid, non-TLS Pilot upgrades are treated as non-confidential and hub registry credential delivery is skipped for that hop. Set this when a TLS-terminating proxy sits in front of the primary and pilots connect through it. | - ## ZFS ARC-aware host memory On OpenZFS hosts (TrueNAS SCALE, Proxmox, ZFS on Ubuntu or Debian) the ZFS ARC cache can hold a large share of RAM. ARC is reclaimable on demand, but the Linux kernel reports it as unavailable, so a naive reading counts ARC as used memory and can raise false host-memory alerts. @@ -249,7 +246,7 @@ services: ## Reverse proxy setup -Sencho works behind any reverse proxy. The only requirement is that WebSocket connections are forwarded correctly (used for live logs, container terminals, and the host console). +Sencho works behind any reverse proxy. Forward WebSocket upgrades, the original client address, and the original request scheme. Set `SENCHO_TRUSTED_PROXY_CIDRS` to the direct proxy as a CIDR (for example, `192.168.1.50/32` for one IPv4 address or `fd12:3456:789a::50/128` for one IPv6 address), or to the proxy network CIDR, so Sencho accepts those forwarding headers only from that peer. ### Nginx @@ -268,6 +265,8 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; } } diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index 4d7f88ec..c8421d31 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -34,7 +34,7 @@ The **Home** view is the default landing page. It is designed for a fast operati - The activity panel shows **Fleet Heartbeat** when remote nodes exist, or **Stack Restarts (7d)** on a local-only install. - **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear. -The top navigation starts with **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. Additional operator views (**Logs**, **Update**, **Schedules**, and **Console**) appear for admins. **Audit** appears based on your role and license tier. Fleet-wide views describe the control instance, so they are hidden while a remote node is active. Choose Classic, Smart, or Compact desktop navigation under **Settings → Appearance → Navigation**; phone navigation stays on its own layout. +The top navigation starts with **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. Additional operator views (**Logs**, **Update**, **Schedules**, and **Console**) appear for admins. **Audit** appears based on your role and license tier. Fleet-wide views describe the control instance, so they are hidden while a remote node is active. Choose Compact (the default) or Smart desktop navigation under **Settings → Appearance → Navigation**; phone navigation stays on its own layout. ## Stack workspace diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index fc099426..5d17f00a 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -107,7 +107,7 @@ You land on **Home**, the default operational view. The health masthead reports Below the stack table, **Configuration Status** summarizes notifications, alerts, automation, security, backups, thresholds, and crash detection. The neighboring activity card shows **Fleet Heartbeat** when remote nodes exist, or **Stack Restarts (7d)** on a local-only install. **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear. -On the local node, baseline top navigation includes **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. **Logs**, **Update**, **Schedules**, and **Console** appear for admins. **Audit** depends on license and role; hub-only views are hidden when a remote node is active. Desktop presentation (Classic bar, Smart bar, or Compact launcher) is chosen under **Settings → Appearance → Navigation**. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Billing** (when a paid license is active), **Documentation**, **Open New Issue**, and **Log Out**. +On the local node, baseline top navigation includes **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. **Logs**, **Update**, **Schedules**, and **Console** appear for admins. **Audit** depends on license and role; hub-only views are hidden when a remote node is active. Desktop presentation (Compact launcher, the default, or Smart bar) is chosen under **Settings → Appearance → Navigation**. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Billing** (when a paid license is active), **Documentation**, **Open New Issue**, and **Log Out**. The left sidebar is the stack workspace. Below the Sencho brand, it starts with the node switcher, then **Create Stack**, a bulk-mode toggle, and **Scan stacks folder** for re-indexing compose projects added outside Sencho. Use **Search stacks...** with the **All**, **Up**, **Down**, and **Updates** chips to narrow the list. On a fresh install with an empty stack list, Sencho scans your mounted compose directory automatically and shows what it found, including compose files that still need to be adopted into their own subfolder. Once stacks carry Docker Compose labels, the list groups them under those labels, with pinned stacks always floating to the top and unlabeled stacks collected at the bottom. diff --git a/docs/git-transport-attestations.yaml b/docs/git-transport-attestations.yaml new file mode 100644 index 00000000..57a2c573 --- /dev/null +++ b/docs/git-transport-attestations.yaml @@ -0,0 +1,365 @@ +# Retained live-evidence results for docs/git-transport-support.yaml claims +# with `evidence.kind: live`. +# +# This is the reproducibility artifact for every non-automated claim: a +# future engineer (or SEN-362's GA verification) can re-run the exact +# procedure in scripts/git-attestation/README.md and compare against what is +# recorded here. Never record raw credential-bearing output, tokens, deploy +# keys, hostnames or URLs beyond generic fixture identities, or fleet +# credentials: only structured pass/fail metadata and scrubbed commands. +# +# Schema (schema: attestation-v1): +# id: referenced by a claim's evidence.attestation. +# date: when the attestation ran (YYYY-MM-DD). +# source_commit: the Sencho commit the claim set's implementation_baseline +# names; a claim binds to this, not to the runtime image digest below. +# sencho_image_digest: the exact runtime image executed. Recorded because +# a runtime attestation executes an image while claims are committed +# from a revision; retained so the executed runtime is identifiable even +# though it is not what claims bind to. +# host: the closed enum value matching the claim (github, gitlab, gitea, +# forgejo, bitbucket, generic); never a descriptive string. +# host_version: optional, self-hosted only: the exact image reference run. +# Omitted for hosted SaaS, where inventing a server version would be false. +# node_path: local | direct-proxy | pilot: the execution path exercised. +# transport / ref / auth / ca: duplicated from the referencing claim so the +# validator can assert exact-dimension equality, not extrapolation. +# repository / ref_name: the exact fixture repository and literal ref +# exercised (a branch, tag, or commit, not the ref *kind*). +# command: a scrubbed, non-credential-bearing description of what ran. +# result: success | rejected, matching the claim's evidence.outcome. + +version: '1' +schema: 'attestation-v1' + +attestations: + # ===== Named hosts, public read-only (HTTPS, no auth) ===== + - id: att-2026-09-01-github-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: github + node_path: local + transport: https + ref: branch + auth: none + ca: system + repository: octocat/Hello-World (GitHub's own public demo repository) + ref_name: master + command: POST /api/git-sources/browse against the real Sencho instance; branch tip resolved and fetched + result: success + + - id: att-2026-09-01-github-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: github + node_path: local + transport: https + ref: sha + auth: none + ca: system + repository: octocat/Hello-World + ref_name: 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d (master tip) + command: POST /api/git-sources/browse pinning the branch tip's own commit SHA + result: success + + - id: att-2026-09-01-gitlab-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitlab + node_path: local + transport: https + ref: branch + auth: none + ca: system + repository: gitlab-org/gitlab-test (GitLab's own canonical test fixture repository) + ref_name: master + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-gitlab-tag + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitlab + node_path: local + transport: https + ref: tag + auth: none + ca: system + repository: gitlab-org/gitlab-test + ref_name: v1.0.0 (annotated) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-gitlab-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitlab + node_path: local + transport: https + ref: sha + auth: none + ca: system + repository: gitlab-org/gitlab-test + ref_name: 6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9 (v1.0.0's peeled commit) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-bitbucket-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: bitbucket + node_path: local + transport: https + ref: branch + auth: none + ca: system + repository: atlassian_tutorial/helloworld (Atlassian's own public tutorial repository) + ref_name: master + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-bitbucket-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: bitbucket + node_path: local + transport: https + ref: sha + auth: none + ca: system + repository: atlassian_tutorial/helloworld + ref_name: 65d938f39f364da3f90767e008022ffe45c562af (master tip) + command: POST /api/git-sources/browse + result: success + + # ===== Self-hosted Gitea, own throwaway instance on the QA fleet ===== + - id: att-2026-09-01-gitea-https-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: https + ref: branch + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Gitea instance, torn down after this pass + ref_name: main + command: POST /api/git-sources/browse with a per-source CA bundle and a Personal Access Token + result: success + + - id: att-2026-09-01-gitea-https-tag + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: https + ref: tag + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Gitea instance + ref_name: v1.0 (annotated) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-gitea-https-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: https + ref: sha + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Gitea instance + ref_name: pinned commit SHA (branch tip) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-gitea-ssh-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: ssh + ref: branch + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Gitea instance + ref_name: main + command: POST /api/git-sources/browse with a read-only deploy key and the host key fetched via Sencho's own probe endpoint + result: success + + - id: att-2026-09-01-gitea-ssh-tag + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: ssh + ref: tag + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Gitea instance + ref_name: v1.0-light (lightweight) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-gitea-ssh-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: ssh + ref: sha + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Gitea instance + ref_name: pinned commit SHA (branch tip) + command: POST /api/git-sources/browse + result: success + + # ===== Self-hosted Forgejo, own throwaway instance on the QA fleet ===== + - id: att-2026-09-01-forgejo-https-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: https + ref: branch + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Forgejo instance, torn down after this pass + ref_name: main + command: POST /api/git-sources/browse with a per-source CA bundle and a Personal Access Token + result: success + + - id: att-2026-09-01-forgejo-https-tag + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: https + ref: tag + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Forgejo instance + ref_name: v1.0 (annotated) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-forgejo-https-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: https + ref: sha + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Forgejo instance + ref_name: pinned commit SHA (branch tip) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-forgejo-ssh-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: ssh + ref: branch + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Forgejo instance + ref_name: main + command: POST /api/git-sources/browse with a read-only deploy key and the host key fetched via Sencho's own probe endpoint + result: success + + - id: att-2026-09-01-forgejo-ssh-tag + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: ssh + ref: tag + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Forgejo instance + ref_name: v1.0-light (lightweight) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-forgejo-ssh-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: ssh + ref: sha + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Forgejo instance + ref_name: pinned commit SHA (branch tip) + command: POST /api/git-sources/browse + result: success + + # ===== Distribution: direct-proxy and Pilot node paths ===== + - id: att-2026-09-01-github-direct-proxy + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: github + node_path: direct-proxy + transport: https + ref: branch + auth: none + ca: system + repository: octocat/Hello-World + ref_name: master + command: POST /api/git-sources/browse with x-node-id targeting a remote proxy-mode fleet node + result: success + + - id: att-2026-09-01-github-pilot + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: github + node_path: pilot + transport: https + ref: branch + auth: none + ca: system + repository: octocat/Hello-World + ref_name: master + command: POST /api/git-sources/browse with x-node-id targeting a Pilot-agent fleet node + result: success diff --git a/docs/git-transport-support.yaml b/docs/git-transport-support.yaml new file mode 100644 index 00000000..0fa585b1 --- /dev/null +++ b/docs/git-transport-support.yaml @@ -0,0 +1,631 @@ +# Git transport support matrix: canonical source of truth (SEN-9 PR 5). +# +# Every entry here is a claim about one exercised combination, not an +# independently-tested dimension: a PAT test, a tag test, and a GitLab +# observation do not jointly prove "GitLab + PAT + tag" unless something +# actually ran that exact combination. `docs/features/git-transport-support.mdx` +# is generated from this file (backend/scripts/git-support-matrix/render.js); +# a backend test (git-support-matrix.test.ts) enforces byte-for-byte parity +# and validates every rule below. Internal test paths, PR numbers, and Linear +# IDs never appear on the published page; they live here instead. +# +# Schema (schema: matrix-v1): +# implementation_baseline: the git commit this file's claims describe. +# claims[]: one entry per exercised (transport, ref, auth, host, ca, +# node_path[, port]) combination. +# - support: supported | unsupported | unverified +# - supported requires evidence with outcome: success +# - unsupported requires evidence with outcome: rejected (a reproducible +# refusal, not silence; an unsupported claim with no evidence is as +# unreproducible as an unproven supported one) +# - unverified forbids evidence entirely +# - evidence.kind: automated (a handle into a test file + exact test +# title) or live (a pointer into docs/git-transport-attestations.yaml). +# Every evidence record repeats all six combination dimensions, and the +# validator requires them to match the claim exactly: a direct-proxy +# result proves the direct-proxy combination, nothing else. +# limitations[]: named gaps, each with its operator-facing consequence. +# error_model[]: the transport-facing error codes this matrix covers +# (TransportFacingCode plus REF_DELETED and FILE_NOT_FOUND), each with +# its HTTP status and one-line meaning. GitOps plan-lifecycle codes +# (STALE_PLAN, PLAN_FINGERPRINT_REQUIRED, PLAN_BLOCKED, LEGACY_PENDING, +# PLAN_UNAVAILABLE, OPERATION_IN_FLIGHT) belong to reconciliation, not +# transport, and are listed separately so the partition is visible. +# attestations: pointer to the retained live-evidence file. + +version: '1' +schema: 'matrix-v1' +implementation_baseline: 79b86ddcd4aefdd6941f098e35990ab397b13c72 +attestations: docs/git-transport-attestations.yaml + +claims: + # ===== Automated: real git, real TLS/SSH, against a local fixture server ===== + # host: generic, node_path: local for all of these: no proxy hop, no + # branded host. Branded-host and non-local node-path claims live below, + # pending or drawn from the live attestation pass. + + - id: https-pat-branch-system-generic-local + transport: https + ref: branch + auth: pat + host: generic + ca: system + node_path: local + support: supported + qualifiers: + - History rewritten on this ref after resolution is detected and rejected as non-fast-forward, not silently deployed. + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: clones a private repo end-to-end with a valid token + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: treats a linear branch advance as a fast-forward + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: treats a multi-commit branch advance as a fast-forward + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: rejects rewritten history as non-fast-forward + + - id: https-pat-tag-system-generic-local + transport: https + ref: tag + auth: pat + host: generic + ca: system + node_path: local + support: supported + qualifiers: + - Proven for both an annotated tag (peeled through its ^{} commit) and a lightweight tag. + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: resolves and fetches an annotated tag through the peeled commit + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: resolves and fetches a lightweight tag + + - id: https-pat-sha-system-generic-local + transport: https + ref: sha + auth: pat + host: generic + ca: system + node_path: local + support: supported + qualifiers: [] + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: resolves and fetches a pinned commit SHA + + - id: https-none-branch-persource-generic-local + transport: https + ref: branch + auth: none + host: generic + ca: per-source + node_path: local + support: supported + qualifiers: + - Also proven to survive a same-origin redirect (the repository relocating on the same host). + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-private-ca.integration.test.ts + title: clones a private-CA HTTPS repo when the per-source CA PEM is supplied + - file: backend/src/__tests__/git-redirect.integration.test.ts + title: resolves a ref through an unauthenticated same-host redirect + + - id: https-pat-branch-persource-generic-local + transport: https + ref: branch + auth: pat + host: generic + ca: per-source + node_path: local + support: supported + qualifiers: + - Proven through a same-origin redirect; the token is forwarded to the relocated path and never offered to a different host. + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-redirect.integration.test.ts + title: resolves a ref through an authenticated same-host redirect and sends the token to the relocated path + + - id: ssh-deploy-key-branch-na-generic-local-nonstandard-port + transport: ssh + ref: branch + auth: deploy-key + host: generic + ca: not-applicable + node_path: local + port: nonstandard + support: supported + qualifiers: + - Full round trip (resolve, fetch, and content verification) proven at this port. + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-transport-ssh.integration.test.ts + title: resolves and fetches over SSH with a deploy key and trusted host key + - file: backend/src/__tests__/git-transport-ssh.integration.test.ts + title: 'resolves over ssh:// with a nonstandard port' + + - id: ssh-deploy-key-branch-na-generic-local-default-port + transport: ssh + ref: branch + auth: deploy-key + host: generic + ca: not-applicable + node_path: local + port: default + support: supported + qualifiers: + - Ref resolution proven at the default port; the full content fetch is proven only at a nonstandard port (see the sibling claim), not separately re-run here. + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-transport-ssh.integration.test.ts + title: resolves over scp-style URL on the default SSH port + + # ===== Live: named Git hosts, external SaaS and self-hosted ===== + # Evidence lives in docs/git-transport-attestations.yaml. A row stays + # `unverified` (no evidence permitted) until a live pass actually exercises + # it; nothing here is extrapolated from a different host or node_path. + # Live-attested 2026-09-01 (see the attestation ids referenced below). + + - id: https-none-branch-system-github-local + transport: https + ref: branch + auth: none + host: github + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; GitHub does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-github-branch + + - id: https-none-tag-system-github-local + transport: https + ref: tag + auth: none + host: github + ca: system + node_path: local + support: unverified + qualifiers: + - Only a public, read-only repository is exercised; GitHub does not receive a token or an SSH deploy key from this attestation. + - Not attested: no small, stable, publicly tagged GitHub repository was found for this pass within a reasonable search. + + - id: https-none-sha-system-github-local + transport: https + ref: sha + auth: none + host: github + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; GitHub does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-github-sha + + - id: https-pat-branch-system-github-local + transport: https + ref: branch + auth: pat + host: github + ca: system + node_path: local + support: unverified + qualifiers: + - Not attested: requires a real GitHub Personal Access Token, which this pass does not hold. + + - id: ssh-deploy-key-branch-na-github-local + transport: ssh + ref: branch + auth: deploy-key + host: github + ca: not-applicable + node_path: local + support: unverified + qualifiers: + - Not attested: requires a real GitHub-registered SSH deploy key, which this pass does not hold. + + - id: https-pat-branch-persource-gitea-local + transport: https + ref: branch + auth: pat + host: gitea + ca: per-source + node_path: local + support: supported + qualifiers: + - Attested against a private repository on a disposable, self-signed Gitea instance; the self-signed certificate is trusted via the per-source custom CA field, not system trust. + - A wrong token against this private repository was separately confirmed to classify as an authentication failure, and a wrong SSH host key as a host-key mismatch. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-https-branch + + - id: https-pat-tag-persource-gitea-local + transport: https + ref: tag + auth: pat + host: gitea + ca: per-source + node_path: local + support: supported + qualifiers: + - Annotated tag, resolved through its peeled commit. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-https-tag + + - id: https-pat-sha-persource-gitea-local + transport: https + ref: sha + auth: pat + host: gitea + ca: per-source + node_path: local + support: supported + qualifiers: [] + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-https-sha + + - id: ssh-deploy-key-branch-na-gitea-local + transport: ssh + ref: branch + auth: deploy-key + host: gitea + ca: not-applicable + node_path: local + support: supported + qualifiers: + - Host key fetched and trusted through Sencho's own probe endpoint, exactly as an operator would. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-ssh-branch + + - id: ssh-deploy-key-tag-na-gitea-local + transport: ssh + ref: tag + auth: deploy-key + host: gitea + ca: not-applicable + node_path: local + support: supported + qualifiers: + - Lightweight tag. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-ssh-tag + + - id: ssh-deploy-key-sha-na-gitea-local + transport: ssh + ref: sha + auth: deploy-key + host: gitea + ca: not-applicable + node_path: local + support: supported + qualifiers: [] + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-ssh-sha + + - id: https-pat-branch-persource-forgejo-local + transport: https + ref: branch + auth: pat + host: forgejo + ca: per-source + node_path: local + support: supported + qualifiers: + - Attested against a private repository on a disposable, self-signed Forgejo instance; the self-signed certificate is trusted via the per-source custom CA field, not system trust. + - A wrong token against this private repository was separately confirmed to classify as an authentication failure, and a wrong SSH host key as a host-key mismatch. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-https-branch + + - id: https-pat-tag-persource-forgejo-local + transport: https + ref: tag + auth: pat + host: forgejo + ca: per-source + node_path: local + support: supported + qualifiers: + - Annotated tag, resolved through its peeled commit. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-https-tag + + - id: https-pat-sha-persource-forgejo-local + transport: https + ref: sha + auth: pat + host: forgejo + ca: per-source + node_path: local + support: supported + qualifiers: [] + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-https-sha + + - id: ssh-deploy-key-branch-na-forgejo-local + transport: ssh + ref: branch + auth: deploy-key + host: forgejo + ca: not-applicable + node_path: local + support: supported + qualifiers: + - Host key fetched and trusted through Sencho's own probe endpoint, exactly as an operator would. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-ssh-branch + + - id: ssh-deploy-key-tag-na-forgejo-local + transport: ssh + ref: tag + auth: deploy-key + host: forgejo + ca: not-applicable + node_path: local + support: supported + qualifiers: + - Lightweight tag. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-ssh-tag + + - id: ssh-deploy-key-sha-na-forgejo-local + transport: ssh + ref: sha + auth: deploy-key + host: forgejo + ca: not-applicable + node_path: local + support: supported + qualifiers: [] + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-ssh-sha + + - id: https-none-branch-system-gitlab-local + transport: https + ref: branch + auth: none + host: gitlab + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; GitLab does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitlab-branch + + - id: https-none-tag-system-gitlab-local + transport: https + ref: tag + auth: none + host: gitlab + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; GitLab does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitlab-tag + + - id: https-none-sha-system-gitlab-local + transport: https + ref: sha + auth: none + host: gitlab + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; GitLab does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitlab-sha + + - id: https-none-branch-system-bitbucket-local + transport: https + ref: branch + auth: none + host: bitbucket + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; Bitbucket does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-bitbucket-branch + + - id: https-none-tag-system-bitbucket-local + transport: https + ref: tag + auth: none + host: bitbucket + ca: system + node_path: local + support: unverified + qualifiers: + - Only a public, read-only repository is exercised; Bitbucket does not receive a token or an SSH deploy key from this attestation. + - Not attested: the public fixture repository used for this pass carries no tags. + + - id: https-none-sha-system-bitbucket-local + transport: https + ref: sha + auth: none + host: bitbucket + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; Bitbucket does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-bitbucket-sha + + # ===== Distribution: does the same combination hold off the local node ===== + # Scoped to host: github (a real branded host reachable over public egress) + # rather than the self-hosted fixtures above: the fleet's inbound firewall + # only opens ports 22 and 1852, so a remote or Pilot node cannot reach a + # container port newly published on the hub, but every node has unrestricted + # outbound egress to the public internet. + + - id: https-none-branch-system-github-direct-proxy + transport: https + ref: branch + auth: none + host: github + ca: system + node_path: direct-proxy + support: supported + qualifiers: + - Proves the direct-proxy path forwards and executes the fetch on the target node; it does not by itself prove any other host, ref, auth, or CA combination on this path. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-github-direct-proxy + + - id: https-none-branch-system-github-pilot + transport: https + ref: branch + auth: none + host: github + ca: system + node_path: pilot + support: supported + qualifiers: + - Proves the Pilot dial-out path forwards and executes the fetch on the target node; it does not by itself prove any other host, ref, auth, or CA combination on this path. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-github-pilot + +limitations: + - id: no-git-lfs + title: No Git LFS + statement: Compose and env files tracked via Git LFS are rejected rather than silently fetched as pointer stubs. Commit plain files instead. + + - id: no-submodules + title: No submodules + statement: Submodule contents are not fetched. Inputs and build contexts that reference submodule contents are refused with an actionable message; a warning is shown when .gitmodules is present. + + - id: no-sparse-partial-clone + title: No sparse or partial clone + statement: Every fetch materializes the complete repository at the resolved commit (shallow, single-branch); there is no sparse or partial clone for large monorepos. + + - id: no-github-app-authentication + title: No GitHub App authentication + statement: Authentication is Personal Access Token or SSH deploy key only. GitHub App installation tokens are not supported. + + - id: no-provider-pull-request-revisions + title: No provider pull/merge request revisions + statement: Sources track a branch, a tag, or a pinned commit SHA. A provider-specific pull request or merge request revision (for example GitHub's refs/pull/N/head) is not a supported ref shape. + + - id: outbound-target-restrictions + title: Outbound target restrictions + statement: Repository targets that resolve to loopback, link-local, multicast, or selected special-use addresses are refused before any request is sent. Private hosts on an operator's own LAN or VPN are not affected by this restriction. + + - id: branch-tag-name-collision + title: Branch/tag name collisions + statement: A bare ref name resolves as a branch first, then as a tag. If an operator renames a branch and a tag of the same name later appears, the source silently starts resolving the tag instead. A ref name that is also a valid 40 or 64 character hex string resolves as a commit SHA before either lookup. + +error_model: + - code: REPO_NOT_FOUND + label: Repository not found + status: 404 + meaning: The repository does not exist, or (indistinguishably, matching GitHub's own private-repo masking) exists but is private and no usable credential was supplied. + - code: AUTH_FAILED + label: Authentication failed + status: 400 + meaning: The Git host rejected the supplied credential. Mapped to 400, never 401, so an upstream Git-host auth failure never triggers the dashboard's own session logout. + - code: SSH_HOST_KEY_FAILED + label: SSH host key mismatch + status: 400 + meaning: The server's SSH host key does not match the fingerprint trusted for this source. + - code: REF_NOT_FOUND + label: Ref not found + status: 404 + meaning: The configured branch, tag, or commit SHA does not exist on the remote. + - code: REF_DELETED + label: Ref deleted or rewritten + status: 404 + meaning: A ref that previously resolved to a commit no longer matches that history (deleted, force-pushed, or superseded by a same-named tag). + - code: UNSUPPORTED_REF + label: Commit not reachable on this host + status: 400 + meaning: A pinned commit SHA that the Git host will not serve because it is not advertised by any branch or tag tip. + - code: RATE_LIMITED + label: Rate limited + status: 429 + meaning: The Git host throttled the request (an HTTP 429, or a sideband message naming a rate limit or abuse-detection mechanism). Wait and retry; there is no automated backoff yet. + - code: NETWORK_TIMEOUT + label: Network timeout + status: 504 + meaning: A connect, fetch, or DNS-resolution timeout, or a target the host actively refused. + - code: GIT_ERROR + label: Git error + status: 400 + meaning: Any other transport failure not covered above (invalid URL, disallowed target, TLS/certificate problem, oversized repository, canceled fetch), classified with a specific operator-facing message. + - code: FILE_NOT_FOUND + label: File not found + status: 404 + meaning: A configured compose or env file path does not exist on the resolved commit. + +reconciliation_only_codes: + # Not part of this transport matrix; listed so the partition of + # GitSourceErrorCode is visible and auditable. Owned by the GitOps + # change-plan lifecycle, not by repository transport. + - STALE_PLAN + - PLAN_FINGERPRINT_REQUIRED + - PLAN_BLOCKED + - LEGACY_PENDING + - PLAN_UNAVAILABLE + - OPERATION_IN_FLIGHT diff --git a/docs/operations/verifying-images.mdx b/docs/operations/verifying-images.mdx index 07d25f12..27376e32 100644 --- a/docs/operations/verifying-images.mdx +++ b/docs/operations/verifying-images.mdx @@ -154,3 +154,17 @@ Maintainers publish these from open PRs for external validation. They are unsign |---|---|---| | `pr-` | `saelix/sencho:pr-1526` | Each re-run of the preview workflow for that PR | | `preview-` | `saelix/sencho:preview-abc1234` | Never (immutable per build) | + +## Reading your running build + +Sencho surfaces the build it is actually running in **Settings → About → Build** (and in the **Channel** and **Current image** rows of **Settings → Admiral Account**). These fields come from the running container's identity, not from the compose file, so a compose edit does not change them until the container is recreated. + +| Field | Meaning | +|---|---| +| **Version** | The packaged semantic version of the build. A dev build keeps the last stable version here, which is why the Channel row matters. | +| **Channel** | The build track of the running image: `Dev`, `Preview`, `Stable`, or `Unknown`. A `dev` or `dev-` image reads `Dev`; a `pr-` or `preview-` image reads `Preview`; a release image reads `Stable`. | +| **Current image** | The image reference the container was started with, for example `ghcr.io/studio-saelix/sencho-dev:dev-a1b2c3d`. | +| **Revision** | The immutable digest this build resolves to, or the pinned `dev-` tag when the running image is on the integration track. | +| **Image ID** | The first twelve characters of the running image's sha256 identifier; click to copy the full id. | + +These fields describe the **control plane** instance you are logged in to, not remote nodes. When identity metadata cannot be determined, the reference fields read `Unknown` rather than guessing. A hardened image viewed by a non-administrator shows `Restricted` for the reference fields instead of `Unknown`. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 82e74f43..49dc6c4e 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -145,7 +145,7 @@ A live preview card shows a sample fleet-status tile so you can see a color choi | Control | What it does | |---------|--------------| -| **Navigation style** | **Smart bar** (recommended default): primary destinations stay visible in the top bar and the rest live under **More**. **Classic bar** keeps the full horizontal strip of destinations (retiring soon; a callout appears while it is selected). **Compact launcher** puts every destination in a menu, with optional quick links. | +| **Navigation style** | **Compact launcher** (recommended default): puts every destination in a menu, with optional quick links. **Smart bar** keeps primary destinations visible in the top bar and the rest live under **More**. | | **Top navigation labels** | On by default. Shows text labels beside the top navigation icons; turn off for a more compact bar with icons only. | Deploy-progress behavior and the diff-preview-before-save step are stack workflow preferences and live in their own [Stacks](#stacks) section under Infrastructure. @@ -167,8 +167,8 @@ Activate, view, or deactivate the license for this Sencho control plane, and see | **Sencho Admiral** | The active license on this control plane, with a tier badge. Community instances see an upgrade prompt here instead. | | **Recovery Vault** | Whether the current subscription includes Recovery Vault entitlement. | | **Hardened Build** | Switches this control plane between the Community image channel and the Admiral Hardened Build channel. Review entitlement and registry access before switching; see [Plans](/features/licensing#feature-breakdown) for what Hardened Build changes. | -| **Current image** | The image reference this control plane is currently running, so you can confirm which channel took effect after a switch. | -| **Channel** | The active image channel (Community or Hardened). | +| **Current image** | The image reference this control plane is currently running, so you can confirm which channel took effect after a switch. A hardened image is shown as `Restricted` to non-administrators. | +| **Channel** | The running image channel this control plane is on: Community, Hardened, or Unknown. Reflects the running build, not the configured target. | | **Customer** | The customer name on file with Lemon Squeezy (paid plans only). | | **Product** | The product (paid plans only). | | **License key** | The active key, masked to the last four characters. | @@ -751,9 +751,15 @@ Displays instance information at a glance. | Field | Description | |-------|-------------| -| **Version** | Current Sencho version. | +| **Version** | The semantic version of this control plane instance. | +| **Channel** | The build track this control plane is running: `Dev`, `Preview`, `Stable`, or `Unknown`. A dev build is identified here even when its packaged version still matches the previous stable release. | +| **Current image** | The image reference this control plane was started with. A compose edit changes the configured target until the container is recreated; this row always shows the running image, never the configured one. | +| **Revision** | The immutable digest or pinned `dev-` tag this build resolves to, or `Unknown` when it cannot be determined. | +| **Image ID** | The first twelve characters of the running image's sha256 identifier. Click to copy the full id. Hidden when no image identity is available. | | **Tier** | Community or Admiral badge. | | **Plan Status** | active, trial, expired, or community (Admiral entitlement state, not the AGPL software license). | | **Instance ID** | First eight characters of the unique identifier for this Sencho control plane (used by the license server to identify it). | +The Build rows describe the **control plane** instance (the Sencho you are logged in to), not remote nodes. When identity metadata is unavailable, the reference rows read `Unknown` rather than inferring a value. For a hardened image seen by a non-administrator, the reference fields read `Restricted` instead. See [Verifying images](/operations/verifying-images) for how the build tracks and immutable tags relate. + The **Links** section contains Source code, AGPLv3 License, Licensing documentation, and Changelog links. diff --git a/docs/tutorials/configure-auto-update-policies.mdx b/docs/tutorials/configure-auto-update-policies.mdx index a716530f..3da659b0 100644 --- a/docs/tutorials/configure-auto-update-policies.mdx +++ b/docs/tutorials/configure-auto-update-policies.mdx @@ -36,7 +36,7 @@ The worked example is `jackett`, a small self-hosted indexer proxy pinned to `ls This is cosmetic confirmation, not the policy itself: the schedule you create next is what actually drives updates. - Open **More → Schedules**, click **New Schedule**, and set **Action** to **Auto-update stacks by label** (in the **Updates** group). Fill in: + Open the navigation launcher and, under **Operations**, pick **Schedules** (Smart bar: **More → Schedules**). Click **New Schedule**, and set **Action** to **Auto-update stacks by label** (in the **Updates** group). Fill in: - **Name**: `Nightly patch check` - **Stack Label**: type `Auto-update` and pick the suggestion that appears (it shows the live match count: `1 stack · 1 node`) @@ -76,7 +76,7 @@ Check from two places, since a status badge alone can't tell you *what* got upda Execution history sheet for Nightly patch check showing one run: Source Manual, Status Success, Duration 24.1s, and details naming the stack-label selector and 'Stack jackett: updated (lscr.io/linuxserver/jackett:latest)'. -**The Update readiness board.** Open **More → Update**. Where jackett's card used to show `Rebuild available`, the board now reads `Everything is up to date`, and the sidebar's **Updates** filter chip is back to `0`. +**The Update readiness board.** Open the navigation launcher and, under **Operations**, pick **Update** (Smart bar: **More → Update**). Where jackett's card used to show `Rebuild available`, the board now reads `Everything is up to date`, and the sidebar's **Updates** filter chip is back to `0`. Update readiness board showing the empty state: a shield icon, the headline 'All stacks on current builds', and the subtitle 'Sencho rechecks registries on the configured interval.' The sidebar's UPDATES chip reads 0. diff --git a/docs/tutorials/grant-scoped-stack-access.mdx b/docs/tutorials/grant-scoped-stack-access.mdx index 83c55c21..034cc3ef 100644 --- a/docs/tutorials/grant-scoped-stack-access.mdx +++ b/docs/tutorials/grant-scoped-stack-access.mdx @@ -83,7 +83,7 @@ Check from two places, since a single screen showing "it looks right" isn't proo The Edit User panel's Scoped Permissions box showing one row: deployer on stack support-portal at Local, with a trash icon to remove it. -**The audit log.** Open **Audit** from the navigation's **More** menu. Two entries confirm the setup, both attributed to your account: a **created user** entry (`POST /api/users`) and an **assigned role** entry (`POST /api/users/:id/roles`). If the teammate restarted the stack in Step 3, a third entry and a `manual` count on the dashboard's **Stack Restarts (7d)** card confirm the permission was exercised, not just granted. +**The audit log.** Open the navigation launcher and pick **Audit** (Smart bar: open **More** instead). Two entries confirm the setup, both attributed to your account: a **created user** entry (`POST /api/users`) and an **assigned role** entry (`POST /api/users/:id/roles`). If the teammate restarted the stack in Step 3, a third entry and a `manual` count on the dashboard's **Stack Restarts (7d)** card confirm the permission was exercised, not just granted. Audit log showing entries for assigning a role (POST /api/users/3/roles) and creating a user (POST /api/users), both attributed to the admin account. diff --git a/docs/tutorials/schedule-an-operation.mdx b/docs/tutorials/schedule-an-operation.mdx index efb74e50..a96e37ad 100644 --- a/docs/tutorials/schedule-an-operation.mdx +++ b/docs/tutorials/schedule-an-operation.mdx @@ -22,10 +22,10 @@ This tutorial covers one recurring **Restart Stack** task on the hub. It does no - In the top navigation, click **More**, then under **Operations** pick **Schedules**. + Open the navigation launcher (the top-left menu icon), then under **Operations** pick **Schedules**. If you use Smart bar instead, click **More** and find Schedules there under the same **Operations** group. - The More navigation menu with Audit under Security & review, Logs, Update, and Schedules under Operations, and Console under Tools. + The navigation launcher's Navigate panel with Audit under Security & review, Logs, Update, and Schedules under Operations, and Console under Tools. The page opens on the **Timeline** view: a 24-hour strip with five lanes, one per operation category (Lifecycle, Updates, Security, Upkeep, Backups). With no tasks yet, it shows the empty-state message `Nothing scheduled in the next 24 hours`. @@ -100,7 +100,7 @@ The footer confirms the schedule is alive: the next run time is shown there too, ## If something goes wrong -**You don't see Schedules in the More menu.** Schedules is a hub-level view. If a remote node is the active selection, the More menu shows only **Console**, and the hub-only views (Schedules, Audit, Logs, Update) are missing. Click the node switcher next to the Sencho logo, pick **Local**, and reopen **More**: Schedules is back under **Operations**. +**You don't see Schedules in the launcher or More menu.** Schedules is a hub-level view. If a remote node is the active selection, the hub-only views (Schedules, Audit, Logs, Update) are missing. Click the node switcher next to the Sencho logo, pick **Local**, and reopen the launcher (or **More**): Schedules is back under **Operations**. A run that fails shows a red **Failed** badge in the row and an error-level notification; the failure notification carries the task name and the error so you can diagnose without opening the run history. The task stays enabled and fires again at its next cron tick, or you can click **Run now** to retry immediately. See [Scheduled Operations · Troubleshooting](/features/scheduled-operations#troubleshooting) for the full failure list. diff --git a/docs/tutorials/set-up-deploy-enforcement.mdx b/docs/tutorials/set-up-deploy-enforcement.mdx index 7ffb0b3e..0ec4b270 100644 --- a/docs/tutorials/set-up-deploy-enforcement.mdx +++ b/docs/tutorials/set-up-deploy-enforcement.mdx @@ -69,7 +69,7 @@ Check from two independent surfaces so you're not trusting a single UI element. **The stack itself.** `prod-web` shows **RUNNING**, with `prod-web-web-1` up and its port mapping live, as in the screenshot above. -**The audit log.** Open **More** → **Audit**. Reading newest first, you'll see the successful bypass deploy, a `policy.bypass` entry naming the policy, the violation count, and the offending image (`policy.bypass stack="prod-web" policy="Production block on critical" violations=1 images=[nginx:1.14]`), and below that the original blocked attempt with a `409` status in red. All three carry the account that triggered them, so the override is attributable, not anonymous. +**The audit log.** Open the navigation launcher and, under **Security & review**, pick **Audit** (Smart bar: **More** → **Audit**). Reading newest first, you'll see the successful bypass deploy, a `policy.bypass` entry naming the policy, the violation count, and the offending image (`policy.bypass stack="prod-web" policy="Production block on critical" violations=1 images=[nginx:1.14]`), and below that the original blocked attempt with a `409` status in red. All three carry the account that triggered them, so the override is attributable, not anonymous. Audit log showing three prod-web entries in order: a 200 deploy, a policy.bypass entry naming policy Production block on critical with violations=1 images=[nginx:1.14], and a 409 blocked deploy. diff --git a/docs/tutorials/set-up-fleet-secrets.mdx b/docs/tutorials/set-up-fleet-secrets.mdx index 6a979c02..3e5fb220 100644 --- a/docs/tutorials/set-up-fleet-secrets.mdx +++ b/docs/tutorials/set-up-fleet-secrets.mdx @@ -90,7 +90,7 @@ Check from two independent surfaces so you're not trusting a single UI element. **The Results tab itself**, shown above: both nodes report success with the counts matching the bundle's key count. -**The Audit Log.** Open **More → Audit**. Reading newest first, the top entry reads `pushed secret: 1` and the one just below it `previewed secret push: 1`, both attributed to the account that ran the push. +**The Audit Log.** Open the navigation launcher and, under **Security & review**, pick **Audit** (Smart bar: **More → Audit**). Reading newest first, the top entry reads `pushed secret: 1` and the one just below it `previewed secret push: 1`, both attributed to the account that ran the push. Audit log showing two recent entries: 'admin pushed secret: 1' and 'admin previewed secret push: 1', each with a timestamp, node, and 200 status. diff --git a/e2e/desktop-navigation.spec.ts b/e2e/desktop-navigation.spec.ts index 58195ca9..ef8a646f 100644 --- a/e2e/desktop-navigation.spec.ts +++ b/e2e/desktop-navigation.spec.ts @@ -1,11 +1,11 @@ /** - * Desktop navigation styles: Smart default, Compact quick-link picker, - * labeled pins, and persistence. + * Desktop navigation styles: Compact default, Smart alternate, labeled pins, + * launcher animation, Navigate panel scrolling, and persistence. */ import { test, expect } from '@playwright/test'; import { loginAs, waitForStacksLoaded } from './helpers'; -async function setTopNavMode(page: import('@playwright/test').Page, mode: 'classic' | 'smart' | 'compact' | null) { +async function setTopNavMode(page: import('@playwright/test').Page, mode: 'smart' | 'compact' | null) { await page.evaluate((next) => { if (next === null) { window.localStorage.removeItem('sencho.appearance.topNavMode'); @@ -30,16 +30,23 @@ test.describe('Desktop navigation styles', () => { await waitForStacksLoaded(page); }); - test('defaults to Smart bar with a More control', async ({ page }) => { + test('defaults to Compact launcher with an Open navigation launcher control', async ({ page }) => { const topbar = page.locator('[data-sn-chrome="topbar"]'); - await expect(topbar).toHaveAttribute('data-sn-nav-mode', 'smart'); - await expect(page.getByRole('button', { name: 'More navigation' })).toBeVisible(); + await expect(topbar).toHaveAttribute('data-sn-nav-mode', 'compact'); + await expect(page.getByRole('button', { name: 'Open navigation launcher' })).toBeVisible(); + }); + + test('a legacy classic preference migrates to compact on load', async ({ page }) => { + await page.evaluate(() => { + window.localStorage.setItem('sencho.appearance.topNavMode', 'classic'); + }); + await page.reload(); + await loginAs(page); + await waitForStacksLoaded(page); + await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'compact'); }); test('persists mode across reload and navigates via Smart More', async ({ page }) => { - await setTopNavMode(page, 'classic'); - await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'classic'); - await setTopNavMode(page, 'smart'); await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'smart'); await page.getByRole('button', { name: 'More navigation' }).click(); @@ -50,7 +57,6 @@ test.describe('Desktop navigation styles', () => { }); test('Compact launcher opens Settings', async ({ page }) => { - await setTopNavMode(page, 'compact'); await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'compact'); await page.getByRole('button', { name: 'Open navigation launcher' }).click(); await page.getByRole('menuitem', { name: /^Settings$/i }).click(); @@ -84,4 +90,152 @@ test.describe('Desktop navigation styles', () => { await expect(page.locator('[data-sn-chrome="topbar"]').getByRole('button', { name: 'Networking', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Add quick link' })).toBeVisible(); }); + + test('the launcher hamburger morphs open/closed and does not animate under Reduced motion', async ({ page }) => { + const trigger = page.getByRole('button', { name: 'Open navigation launcher' }); + await expect(trigger).toHaveAttribute('data-state', 'closed'); + await trigger.click(); + await expect(trigger).toHaveAttribute('data-state', 'open'); + await page.keyboard.press('Escape'); + await expect(trigger).toHaveAttribute('data-state', 'closed'); + + // The bar actually moves open vs. closed, not just a duration-clamp check. + // Read translate and rotate alongside transform: Tailwind v4 compiles these + // utilities to the standalone `translate` and `rotate` properties, so reading + // `transform` alone reports "none" in both states and proves nothing. Keeping + // transform in the snapshot means this still holds if that ever changes back. + const bar = trigger.locator('span > span').first(); + const morphState = (el: Element) => { + const s = getComputedStyle(el); + return `${s.translate}|${s.rotate}|${s.transform}`; + }; + const closedMorph = await bar.evaluate(morphState); + await trigger.click(); + await expect(trigger).toHaveAttribute('data-state', 'open'); + const openMorph = await bar.evaluate(morphState); + expect(openMorph).not.toBe(closedMorph); + await page.keyboard.press('Escape'); + await expect(trigger).toHaveAttribute('data-state', 'closed'); + + // Drive Reduced motion explicitly in both directions rather than assuming the + // starting state: a fresh install defaults to the Calm visual style, which + // turns Reduced motion on, so the clamp is already active before any toggle. + // The top bar stays mounted on the Settings view, so the bar can be measured + // from there without navigating back. + await trigger.click(); + await page.getByRole('menuitem', { name: /^Settings$/i }).click(); + await page.getByText('Appearance', { exact: true }).first().waitFor(); + const reducedMotion = page.getByRole('switch', { name: 'Reduced motion' }); + const durationMs = () => bar.evaluate((el) => parseFloat(getComputedStyle(el).transitionDuration) * 1000); + + if (await reducedMotion.getAttribute('aria-checked') === 'true') { + await reducedMotion.click(); + } + await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); + expect(await durationMs()).toBeGreaterThan(1); + + await reducedMotion.click(); + await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduced'); + expect(await durationMs()).toBeLessThan(1); + }); + + test('the Navigate panel actually scrolls to reach destinations below the fold', async ({ page }) => { + await page.setViewportSize({ width: 1200, height: 420 }); + const trigger = page.getByRole('button', { name: 'Open navigation launcher' }); + await trigger.click(); + + const panel = page.getByRole('menu').filter({ has: page.getByText('Navigate', { exact: true }) }); + const viewport = panel.locator('[data-radix-scroll-area-viewport]'); + await expect(viewport).toBeVisible(); + + // A shrunken nav set would trip the overflow assertion below with a confusing + // message, so fail here first, naming the real cause. + expect(await panel.getByRole('menuitem').count()).toBeGreaterThan(8); + + // The viewport must have real internal overflow. This is the assertion that + // matters: the panel previously rendered at its full content height, reported + // scrollHeight === clientHeight, and was merely clipped by an ancestor, so it + // looked capped while ignoring every wheel event. Checking only the computed + // overflow-y properties passes in exactly that broken state. + const metrics = await viewport.evaluate((el) => ({ + clientHeight: el.clientHeight, + scrollHeight: el.scrollHeight, + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + viewportOverflowX: getComputedStyle(el).overflowX, + outerOverflowY: getComputedStyle(el.closest('[role="menu"]') as HTMLElement).overflowY, + })); + expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight); + + // Exactly one scroll owner: the outer menu clips rather than scrolling. Its + // scrollHeight is not asserted, because the menu's 1px border alone puts it a + // couple of pixels over its clientHeight without it being scrollable at all. + expect(metrics.outerOverflowY).toBe('hidden'); + + // No horizontal overflow, and the panel stays inside the browser viewport. + // overflow-x is checked directly, not just measured: a reserved scrollbar + // gutter from overflow-x: scroll would pass the width comparison below with + // no actual overflow present. + expect(metrics.viewportOverflowX).not.toBe('scroll'); + expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth + 1); + const box = await panel.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.y + box!.height).toBeLessThanOrEqual(420 + 1); + + // Keyboard reaches a destination below the fold and brings it fully into view. + // End rather than ArrowDown, because ArrowDown landing on the first item is + // stock roving focus and holds whether or not anything scrolls. Done before + // any pointer movement, since Radix focuses a menu item on pointermove. + const last = panel.getByRole('menuitem').last(); + await page.keyboard.press('End'); + await expect(last).toBeFocused(); + await expect(last).toBeInViewport({ ratio: 1 }); + + // Back to the top so the wheel below starts from a known position. + await viewport.evaluate((el) => { el.scrollTop = 0; }); + + // Genuine mouse-wheel input over the panel must move it. Wheel input is the + // exact path the regression ignored, so drive it rather than assigning scrollTop. + const vpBox = await viewport.boundingBox(); + await page.mouse.move(vpBox!.x + vpBox!.width / 2, vpBox!.y + vpBox!.height / 2); + await page.mouse.wheel(0, 200); + await expect.poll(() => viewport.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + + // ...and it moved the viewport only, leaving the outer menu at rest: an + // overflow-hidden element cannot be wheel-scrolled, so this is a fixed + // invariant rather than something to poll for. + expect(await panel.evaluate((el) => el.scrollTop)).toBe(0); + + // Keep wheeling to the bottom rather than assuming one gesture covers the whole + // range, so adding destinations later cannot fail this for a reason unrelated + // to scrolling. + await expect.poll(async () => { + await page.mouse.wheel(0, 200); + return viewport.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop); + }).toBeLessThanOrEqual(1); + + // The last destination is genuinely reachable by mouse, not just present. + await expect(last).toBeInViewport({ ratio: 1 }); + }); + + test('the Navigate panel sizes to its content when the viewport is tall', async ({ page }) => { + // The mirror of the test above, guarding the other direction: the cap has to + // track the popper's available height rather than a fixed pixel value. A + // hardcoded cap would keep every assertion above green while needlessly + // cropping the panel on a roomy screen. + await page.setViewportSize({ width: 1400, height: 900 }); + const trigger = page.getByRole('button', { name: 'Open navigation launcher' }); + await trigger.click(); + + const panel = page.getByRole('menu').filter({ has: page.getByText('Navigate', { exact: true }) }); + const viewport = panel.locator('[data-radix-scroll-area-viewport]'); + await expect(viewport).toBeVisible(); + + // Content fits without being clipped when the viewport is roomy enough. + const metrics = await viewport.evaluate((el) => ({ + clientHeight: el.clientHeight, + scrollHeight: el.scrollHeight, + })); + expect(metrics.scrollHeight).toBeLessThanOrEqual(metrics.clientHeight + 1); + }); }); diff --git a/e2e/editor-save-deploy.spec.ts b/e2e/editor-save-deploy.spec.ts index 981bc1cc..2b04cf28 100644 --- a/e2e/editor-save-deploy.spec.ts +++ b/e2e/editor-save-deploy.spec.ts @@ -11,6 +11,36 @@ import { test, expect } from '@playwright/test'; import { loginAs, waitForStacksLoaded } from './helpers'; const TEST_STACK = 'e2e-save-deploy-stack'; +const REMOTE_NODE_NAME = 'e2e-editor-pin-node'; + +// The two-tab test needs a second node so the NodeSwitcher offers a switch. +// Seeded via the API (like deleteTestNodes in pilot-agent-enrollment.spec.ts) +// because a fresh install has only the local node, and the switcher hides its +// trigger entirely with a single node. Pilot-agent mode is used because +// proxy-mode node creation SSRF-validates api_url and loopback/TEST-NET +// targets are rejected even with the e2e loopback flag set. A pilot-agent node +// is a remote row with no api_url, which is all the switcher needs. +async function seedRemoteNode(page: import('@playwright/test').Page): Promise { + const res = await page.request.post('/api/nodes', { + data: { name: REMOTE_NODE_NAME, type: 'remote', mode: 'pilot_agent' }, + }); + if (!res.ok()) { + throw new Error(`Could not seed the remote node for the two-tab editor test (${res.status()})`); + } + const body = (await res.json()) as { id: number }; + return body.id; +} + +async function deleteSeededNode(page: import('@playwright/test').Page): Promise { + const list = await page.request.get('/api/nodes'); + if (!list.ok()) return; + const nodes = (await list.json()) as Array<{ id: number; name: string }>; + for (const n of nodes) { + if (n.name === REMOTE_NODE_NAME) { + await page.request.delete(`/api/nodes/${n.id}`).catch(() => undefined); + } + } +} async function deleteTestStack(page: import('@playwright/test').Page) { await page.evaluate(async (name) => { @@ -73,4 +103,76 @@ test.describe('EditorView save-and-deploy', () => { await page.waitForTimeout(1_000); expect(deployAttempts).toBe(0); }); + + // #1854 regression: two tabs of the same Sencho instance share + // localStorage['sencho-active-node'] with no storage-event listener. When tab + // 2 switches nodes, tab 1's unpinned editor requests silently retarget to tab + // 2's node and Save & Deploy fails with a confusing error (refreshing tab 1 + // "fixed" it). The editor chain must pin every request to the node its tab + // captured, so the PUT still carries tab 1's node id and succeeds. + test('save succeeds in tab 1 after tab 2 switched the shared active node', async ({ page, context }) => { + // Tab 1's identity is the local node; capture whatever x-node-id it sends + // so the assertion does not hardcode the dev DB's id sequence. + let tab1NodeId: string | null = null; + await page.route(`**/api/stacks/${TEST_STACK}`, async (route, req) => { + if (req.method() === 'PUT' && tab1NodeId === null) { + tab1NodeId = await req.headerValue('x-node-id'); + } + await route.continue(); + }); + // Count the deploy POST directly: visible "deploy" text also matches the + // toolbar button, so it cannot prove the deploy was attempted. + let deployAttempts = 0; + await page.route(`**/api/stacks/${TEST_STACK}/deploy*`, async (route, req) => { + if (req.method() === 'POST') deployAttempts += 1; + await route.continue(); + }); + + // Tab 2, same browser context (shared cookies AND localStorage): switch to + // a remote node through the normal UI. This rewrites sencho-active-node + // behind tab 1's back, exactly the user-reported sequence. + const tab2 = await context.newPage(); + try { + await seedRemoteNode(page); + await tab2.goto('/'); + await loginAs(tab2); + await waitForStacksLoaded(tab2); + await tab2.reload(); + await loginAs(tab2); + await waitForStacksLoaded(tab2); + await tab2.getByRole('button', { name: 'Switch node' }).click(); + await tab2.getByRole('button', { name: REMOTE_NODE_NAME }).first().click(); + await expect(tab2.getByRole('button', { name: 'Switch node' })).toContainText( + new RegExp(REMOTE_NODE_NAME, 'i'), + { timeout: 10_000 }, + ); + + // Capture the shared key after the switch; the inequality against + // tab1NodeId below is what proves tab 1's PUT ignored it. + const sharedKey = await page.evaluate(() => localStorage.getItem('sencho-active-node')); + expect(sharedKey).not.toBeNull(); + + // Tab 1 never reloaded; its React state still shows the local node, but + // pre-fix its save PUT would follow the rewritten localStorage key. + await page.getByRole('button', { name: 'Save & Deploy', exact: true }).click(); + + // The save must succeed against tab 1's own node: success toast, no + // failure toast, and the deploy POST must actually go out (counted, not + // inferred from visible text). The deploy fires after the async + // pre-deploy advisory fetch, so poll the counter instead of asserting + // synchronously after the save toast. + await expect(page.getByText('File saved successfully!')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/failed to save file/i)).toHaveCount(0); + await expect.poll(() => deployAttempts, { timeout: 15_000 }).toBeGreaterThanOrEqual(1); + + // The PUT carried tab 1's captured node id, not the key tab 2 rewrote. + // Both ids are decimal strings from the same DB, so string inequality + // plus tab 1's success toast is the whole assertion. + expect(tab1NodeId).not.toBeNull(); + expect(tab1NodeId).not.toBe(sharedKey); + } finally { + await deleteSeededNode(page); + await tab2.close(); + } + }); }); diff --git a/e2e/external-deps.spec.ts b/e2e/external-deps.spec.ts new file mode 100644 index 00000000..402848f7 --- /dev/null +++ b/e2e/external-deps.spec.ts @@ -0,0 +1,49 @@ +/** + * Unit coverage for the shared dependency probes in externalDeps.ts. + * + * Not a browser test: these run against the probe functions directly with an + * injected predicate, so absence of git/sshd can be exercised without + * removing system binaries. + */ +import { test, expect } from '@playwright/test'; +import { requireGitBinary, requireSshd } from './externalDeps'; + +test.describe('external dependency probes', () => { + test.afterEach(() => { + delete process.env.CI; + }); + + test('requireGitBinary returns true when git is present, locally or in CI', () => { + delete process.env.CI; + expect(requireGitBinary(() => true)).toBe(true); + process.env.CI = '1'; + expect(requireGitBinary(() => true)).toBe(true); + }); + + test('requireGitBinary returns false when git is absent locally', () => { + delete process.env.CI; + expect(requireGitBinary(() => false)).toBe(false); + }); + + test('requireGitBinary throws when git is absent under CI', () => { + process.env.CI = '1'; + expect(() => requireGitBinary(() => false)).toThrow(/git is required in CI/); + }); + + test('requireSshd returns true when sshd is present, locally or in CI', () => { + delete process.env.CI; + expect(requireSshd(() => true)).toBe(true); + process.env.CI = '1'; + expect(requireSshd(() => true)).toBe(true); + }); + + test('requireSshd returns false when sshd is absent locally', () => { + delete process.env.CI; + expect(requireSshd(() => false)).toBe(false); + }); + + test('requireSshd throws when sshd is absent under CI', () => { + process.env.CI = '1'; + expect(() => requireSshd(() => false)).toThrow(/sshd is required in CI/); + }); +}); diff --git a/e2e/externalDeps.ts b/e2e/externalDeps.ts new file mode 100644 index 00000000..80fb4bd6 --- /dev/null +++ b/e2e/externalDeps.ts @@ -0,0 +1,49 @@ +/** + * Shared availability probes for the real-git and real-sshd E2E fixtures. + * + * Mirrors backend/src/__tests__/__helpers__/externalDeps.ts. Kept as a + * separate file rather than a shared import: backend's tsconfig pins + * `rootDir` to backend/src, so a cross-directory import would fail + * `tsc --noEmit` there. + * + * `gitServer.helper.ts` and `sshGit.helper.ts` used to each probe with their + * own local `spawnSync` check and let a missing dependency silently skip the + * spec, in CI as well as locally. These wrappers keep that local-dev + * behavior but throw under CI, where the dependency is expected to be + * present and a skip would be a false claim of coverage. + */ +import { spawnSync } from 'child_process'; + +export type DependencyProbe = () => boolean; + +export const defaultGitProbe: DependencyProbe = () => + spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; + +export const defaultSshdProbe: DependencyProbe = () => + spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0; + +function requireDependency(name: string, hint: string, probe: DependencyProbe): boolean { + const present = probe(); + if (!present && process.env.CI) { + throw new Error(`${name} is required in CI but was not found. ${hint}`); + } + return present; +} + +/** True when the system `git` binary is available; throws under CI if not. */ +export function requireGitBinary(probe: DependencyProbe = defaultGitProbe): boolean { + return requireDependency( + 'git', + 'Ensure the CI image installs the git CLI before running the E2E suite.', + probe, + ); +} + +/** True when a local `sshd` binary is available; throws under CI if not. */ +export function requireSshd(probe: DependencyProbe = defaultSshdProbe): boolean { + return requireDependency( + 'sshd', + 'Ensure the CI image installs openssh-server and frees loopback port 22 (see .github/workflows/ci.yml).', + probe, + ); +} diff --git a/e2e/fixtures/git-private-ca.key b/e2e/fixtures/git-private-ca.key new file mode 100644 index 00000000..a5186851 --- /dev/null +++ b/e2e/fixtures/git-private-ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCNoCwEyqzOz1Ow +ByTDbmpKjTJSa/+4byeIqg1Hs5soLKG2U1xaLelS41KotBV9+Rl8Yw34D1OJhBs9 +NVNidDH2PmWWOhz/hmwoDtOVSJVWeT+N4HidCPRg/dHAAFQ+jKl+k8nnrhv0XZx3 +7rgEvhEwBu+zVMzkrlt3NxWcOV/S+tz2Jk4Mrnh6rws8hm4Wwqgwm0yfT3efBwG6 +KcWWYlTUer7qBl6P2wB3nu6IMEYYYaQuvKdiumwWghraySEqYYxs3TCXJLztFy7u +LPQh9HG8bRnJ8Bzwi01iAGq5/npJ+sw/EvM3zZgGt2Q2d+u3+UO+ZkoRG7R9PcOe +ulx8UR3fAgMBAAECggEADIl8zBHK+WXGEUOYoApCs4XLShuQYBnK5KC1Gz9NgWFu +EU9Q0hTVXkaMVy3V5zhpZqb4IhjtOrOsm58AWitSDuvYH1PWpFIYefVSCpmJysVh ++GPCGZik1X8yla4RxxW5nWBk07MIe3nb84v400amC9vZVUUw/B9pLmT8bz9u+aSk +sfGHjrM3TxBUAUxEg4BOa9P9y2ii59C9EPprKECJi8s3mKYLTFodeB32GAMDWbdE +1OunPCJd3zXRK+S2IqXQMnW1XjbeUN87FC6B1UTsonHBN2tCa96u3CxWQbsmSa9m +4HRBmRnt5egGV1VygudL9v8WQF034JI4/RnjSR2YcQKBgQDHonkG5SXc+zH7wpit +a78hFsSCSLtq1boAvr99Br12H9haEj7gzgNyXWt5Y/Cl3thtQjXMCQzeRrf7ldXU +Srj6DAE4Wc+Dorm4q/ny2wcJYuBFz8c9/7ghuoLtEXRSMNUjwQbM4IH55ZF8IaA+ +iboYQxZMXfC5SzeFQieqAo38jwKBgQC1nNQnQ0yYTvzIGJh8hlpDB6QEdQz8o14h +UThIOP42hlsoEIUPpOB0WCK5bbbn65TLsCmRB0+yUq65jGV59IKyLniPa6Ic6BI6 +eJTVNFxeNN0hlouLeD6bw5cQtPzL5zWmu9GXcYypA5CMqFKZBXt5fAQmDkS36Wjo +aTLb/6ARsQKBgQCZHGtmdmlLyvzS8rTWjUTRw/yDT/UuQy2dVK7Y3UqCRnpQ2p2P +HXJXTH8ZYyU2kmu7oIRSML7F28dQFeMiJw0n+f0VkwwtEakPkhbpxELpWARahrlx +O6eldr7jw/dK8lkGSw1EJQyK9R9X7RJR5J/t68Y2W/Y8pwu2EL8LDVqI0QKBgBmk +6XgZ0qj3Dk6a2n1K41fvrkNK2+iYkOQXeeEI2yyL0DdaDc/lsiP7hfu0+EzLQRl5 +6ISoCaLedfmRT4rm8cWDNlbaFewLAPfsqudoG1raEBd8EHxDIGQSPDSJueB452SB +xNijmf8Ll8+kvPUKhyLiVhuhjCaD+OJIaHwUHmAhAoGBALtbJl7KEjVsAsbcyJnW +Tx4Vxk049VFDq+PPfblpTRGn81wevguZKC+wVyR5SjAOAuojB3ZVenqj8CtICNvS +hFbuaJnvW3FeuSXiG6XjtCYR3w+rOTYNHPuc/iWaropBiLke8VPELPuLmFQjETmi +iQEkFa/DEzbToo1kLlpCY0CM +-----END PRIVATE KEY----- diff --git a/e2e/fixtures/git-private-ca.pem b/e2e/fixtures/git-private-ca.pem new file mode 100644 index 00000000..c2ee7764 --- /dev/null +++ b/e2e/fixtures/git-private-ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIULvaaN8MsB+OhUMMrVKw7Ki/9F/YwDQYJKoZIhvcNAQEL +BQAwIzEhMB8GA1UEAwwYU2VuY2hvIEUyRSBQZXItU291cmNlIENBMB4XDTI2MDgz +MTAxMTQwNloXDTM2MDgyODAxMTQwNlowIzEhMB8GA1UEAwwYU2VuY2hvIEUyRSBQ +ZXItU291cmNlIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjaAs +BMqszs9TsAckw25qSo0yUmv/uG8niKoNR7ObKCyhtlNcWi3pUuNSqLQVffkZfGMN ++A9TiYQbPTVTYnQx9j5lljoc/4ZsKA7TlUiVVnk/jeB4nQj0YP3RwABUPoypfpPJ +564b9F2cd+64BL4RMAbvs1TM5K5bdzcVnDlf0vrc9iZODK54eq8LPIZuFsKoMJtM +n093nwcBuinFlmJU1Hq+6gZej9sAd57uiDBGGGGkLrynYrpsFoIa2skhKmGMbN0w +lyS87Rcu7iz0IfRxvG0ZyfAc8ItNYgBquf56SfrMPxLzN82YBrdkNnfrt/lDvmZK +ERu0fT3DnrpcfFEd3wIDAQABo1MwUTAdBgNVHQ4EFgQUpKsuDlFzVAUSp9V60cyO +ALpZyjgwHwYDVR0jBBgwFoAUpKsuDlFzVAUSp9V60cyOALpZyjgwDwYDVR0TAQH/ +BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAdv4neSsRh5pxRqCEZZ2R+HJkkOCw +KDlvcW5WBRPvRjdMG/3vCSMZIpEW6yz4xYR8NCXdAwg/QNf6qegFf6NElKYurqNf +NKyOmRSpggFlFH+s2FK1qdFMSncd2JFr3jTeoQpL/6ykJfBjyACFyDtHpauTrIKL +E8cI2dV4/j1r9dQJY1s78KChbb6HaU3orPn1EuG46AFhtG+IwJ9MF0TWcj+tZ/rp +Ax/UlgTw0wTh9RvABBnenaD7V5DP3lN1EPaARJ9NJijO5TnaytPbbiIuu1x76/Az +fsNEavnmd8EZ5foDK3rVGNjMZjUQf3u79KhDmGCPUv+jF7uMw6gmcwu9Mg== +-----END CERTIFICATE----- diff --git a/e2e/fixtures/git-private-server.key b/e2e/fixtures/git-private-server.key new file mode 100644 index 00000000..dc4659dc --- /dev/null +++ b/e2e/fixtures/git-private-server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCbbUxC0xx6iXbp +VhwlZ1haH8bdSxeRjH0/lY/l+KS6+P0K5q6ARYkL4exurEIz6Ofjsi7tOJzhCEIq +MoxaM9hdvwt5PBbj2MRQCVjo6SXSmgP0uB7Wv9uC7pwC0zGo3qt2tzvKCWe/ZePr +UG/vOZ60H56iTa4ELIGseW01bu1QXZ75knpae3xemcAngjXxfKAxEfYkRgJuiAlG +ESEkskkY+yDS8c54qn498yRVHzJYURV5WYMPrr17Abd9mw4P82RM/IAGrKcS3b2I +wl61OBroFkA5Lf0qinAjgmnl6DTjEpZDu5F/yE4Plqkw6WxLzgufAdmv8WixcHEw +oKkpIHUnAgMBAAECggEANingHheAwKkf8c+qzlQV445YWGzfQT8StLJTq8I68dds +Izzhidzxldz87lKEXZ+oE97X4J5OeVNN73OfGp4fpAe8IVsR5QP44aVoQP5iymIW +x9TUFmVUw2uQnaFomF9EpIHVSaJ+b6I7y5jD8TuEtWOhfhEQ9+5koCzOpITMGamD +QJKkNAsBeI4hMf4oWOpBX+aYeahtguWCUV14jvFMbxDknComHNv20gxgFNPEmI8+ +07Q9HkQ8F2qV5k9KDQcUdqJRXUIectZVBGLi2WN2mMp1x/vws0zZK15lwEbiELVY +kURpO6dP/EW3ir+pIEYkItIxLEggRYijeV4uJiCxKQKBgQDT/m0KnV4tWVXU2jzo +kKEpplhpEfsKknruhQa0GGw04qF4ykyjS2KgtzjdTs7WWWE+FxXtBuIX8m1s0cVg +Wq8HbU7efBsyRhq+HG1owJUzCe3yQ4AO7pXCkIcYY0VPV9Yk7dztIEZ1Qo+GfRwD +sABGybC8yriD/0m4+JZsuMKD3wKBgQC7sNj+HX0EjUxfpr/UyLKXXbndxkG4AMiH +uaOzXSY5VTjeH3KBfidhFQ20pGdbFCEpsAKHu0AnHucw4sHif+SroePquwOohPJ7 +NukCK6W2usws//Sp8Jcu0weuRQSl0xIlxImtQW69RTF2nrc4tnHmVntSKsnWkhJa +PctNWw33uQKBgD50utNhwZlCtJLdKQyrb4/BvlJWRcu7lBQphOwSNe7uxfu8Pg/t +6cTHti0dRrrH4mpUitUmLf44IhzpQGk+zko13gKWNbz+Amr4HRO7iTlcN4okcNn1 +WJHV2rdIp+bUTfbbTTdfRuLNFVPeEB7V/37bdQJqByp8T8/7DPZDCKupAoGASW7a +pymQZTyHOhE6kpznStOPydYsljowOvIFu0JhlyLhuf4hxco+y/v5vcho67iHdRD5 +HHPFmMi9eWHuq5iQNhqD2q3Ks584Y77LEV9UWZbiFWUbK3YHIHnOUn+MXvii7AXm +O9QS6JhuztMwKk8vZwhE/ZPiHkJOTeJJbX2HjHkCgYByrfnbZ1HA9tcNRHtj/eHY +I8qvP/428Dm4VQLgo8G2chMZ+ZeVB0IDyyk/6gtEBdbhdo5VQYM5mEQu0PiSXT4N +Mn8+rmK8i/eZrfBFYOVe/nyXyL+5OYjB8QsFYIo5lRjBsZsMF/ToSA9oRR0Z/54g +FqXQwVLTojvFhQe319Ofbw== +-----END PRIVATE KEY----- diff --git a/e2e/fixtures/git-private-server.pem b/e2e/fixtures/git-private-server.pem new file mode 100644 index 00000000..791afc9d --- /dev/null +++ b/e2e/fixtures/git-private-server.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDLjCCAhagAwIBAgIUaasHL04dWwBLrG4H7OaLJnlna9AwDQYJKoZIhvcNAQEL +BQAwIzEhMB8GA1UEAwwYU2VuY2hvIEUyRSBQZXItU291cmNlIENBMB4XDTI2MDgz +MTAxMTQwNloXDTM2MDgyODAxMTQwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm21MQtMceol26VYcJWdYWh/G +3UsXkYx9P5WP5fikuvj9CuaugEWJC+HsbqxCM+jn47Iu7Tic4QhCKjKMWjPYXb8L +eTwW49jEUAlY6Okl0poD9Lge1r/bgu6cAtMxqN6rdrc7yglnv2Xj61Bv7zmetB+e +ok2uBCyBrHltNW7tUF2e+ZJ6Wnt8XpnAJ4I18XygMRH2JEYCbogJRhEhJLJJGPsg +0vHOeKp+PfMkVR8yWFEVeVmDD669ewG3fZsOD/NkTPyABqynEt29iMJetTga6BZA +OS39KopwI4Jp5eg04xKWQ7uRf8hOD5apMOlsS84LnwHZr/FosXBxMKCpKSB1JwID +AQABo2kwZzAaBgNVHREEEzARhwR/AAABgglsb2NhbGhvc3QwCQYDVR0TBAIwADAd +BgNVHQ4EFgQUd/WBllcfIKFQ13sV85/7YYa1C7MwHwYDVR0jBBgwFoAUpKsuDlFz +VAUSp9V60cyOALpZyjgwDQYJKoZIhvcNAQELBQADggEBAIwyjBNYFmYrheQXphqg +KBcgVNyhKBG3kbvMiTRLohS5995cKSHuDcfBwYi2XjnJBLJm+l3DjgjFqr64zY77 +tVhuupqb9JL3JWC+C/6Pe1uSolsI+7lkUJx4woUs74++oNdeB368mlsVPy0j2NKr +RyZsCGGbzF7HzWlRj2oH/MVNBI6hszlDxDbJyaTP59zVCudSfI1K2zNgMdxxmmXH +R3x3RKse9UyGjox4aXaY75fFZIiUEbiOHsWqLvZMrRN4ns7D8yLuLnQxxTDjl7QI +4qHUQ7h/fdq6luKp35/3hkYn10t4ocw1Nb3nOUm2DFuvTTFs6lGYSEFmbRtFA2qI +Kxk= +-----END CERTIFICATE----- diff --git a/e2e/git-source-ca.spec.ts b/e2e/git-source-ca.spec.ts new file mode 100644 index 00000000..4e5d4b78 --- /dev/null +++ b/e2e/git-source-ca.spec.ts @@ -0,0 +1,198 @@ +/** + * Per-source custom CA bundle: end-to-end through the product boundary. + * + * Drives the full chain - API PUT (encrypted at rest) -> API GET (project + * exposes has_ca_bundle, never the PEM) -> real HTTPS fetch against a + * locally-served fixture repo -> API PUT with remove_ca_bundle=true -> + * API GET confirming the stored PEM was cleared -> a fetch that must now + * fail on TLS trust. + * + * This fixture server presents a certificate signed by a SEPARATE CA that + * nothing else trusts: it is not the shared dev/E2E CA, so it is absent from + * the backend's NODE_EXTRA_CA_CERTS and from system trust. That isolation is + * the whole point of the spec. The stored per-source bundle is then the only + * thing that can make the fetch succeed, so the test fails if the CA ever + * stops reaching native git, and removing it must produce a real trust + * failure rather than a result the assertion tolerates. + */ +import { test, expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { loginAs } from './helpers'; +import { gitAvailable, buildFixtureRepo, serveRepos } from './gitServer.helper'; + +const CA_PEM = fs.readFileSync( + path.join(process.cwd(), 'e2e', 'fixtures', 'git-private-ca.pem'), + 'utf8', +); + +const APP_FILES = { + 'compose.yaml': 'services:\n x:\n image: nginx\n', +}; + +test.describe('Git Sources per-source CA bundle (product boundary)', () => { + test.skip(!gitAvailable(), 'system git binary is not available'); + + let server: { url: string; close: () => void }; + let stackName: string; + + test.beforeAll(async () => { + server = await serveRepos({ + app: buildFixtureRepo(APP_FILES), + }, 'git-private-server'); + }); + + test.afterAll(() => { + server?.close(); + }); + + test.beforeEach(async () => { + stackName = `e2e-ca-${Date.now()}`; + }); + + test.afterEach(async ({ page }) => { + await page.evaluate(async (name) => { + await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + }, stackName); + }); + + test('API stores encrypted CA, exposes has_ca_bundle, never returns PEM, and explicit remove clears it', async ({ page }) => { + await loginAs(page); + await page.evaluate(async (name) => { + const res = await fetch('/api/stacks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ stackName: name }), + }); + if (res.status !== 200) throw new Error(`create stack failed: ${res.status}`); + }, stackName); + + const repoUrl = `${server.url}/app.git`; + + // Step 1: PUT with a per-source CA bundle. + const putRes = await page.evaluate(async ({ name, repoUrl, pem }) => { + const res = await fetch(`/api/stacks/${name}/git-source`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + repo_url: repoUrl, + branch: 'main', + compose_paths: ['compose.yaml'], + auth_type: 'none', + ca_bundle: pem, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + }), + }); + return { status: res.status, body: await res.json() }; + }, { name: stackName, repoUrl, pem: CA_PEM }); + expect(putRes.status).toBe(200); + expect(putRes.body.has_ca_bundle).toBe(true); + // The PEM must not appear anywhere in the PUT response. + expect(JSON.stringify(putRes.body)).not.toContain('BEGIN CERTIFICATE'); + + // Step 2: GET confirms the persisted state and still hides the PEM. + const getRes = await page.evaluate(async (name) => { + const res = await fetch(`/api/stacks/${name}/git-source`, { credentials: 'include' }); + return { status: res.status, body: await res.json() }; + }, stackName); + expect(getRes.status).toBe(200); + expect(getRes.body.has_ca_bundle).toBe(true); + expect(JSON.stringify(getRes.body)).not.toContain('BEGIN CERTIFICATE'); + + // Step 3: a real fetch against the per-source-CA-configured repo + // succeeds. This exercises the full product boundary: encrypted row + // -> decryption -> combined CA file -> real git fetch. + const pull = await page.evaluate(async (name) => { + const res = await fetch(`/api/stacks/${name}/git-source/pull`, { + method: 'POST', + credentials: 'include', + }); + return { status: res.status, body: await res.json() }; + }, stackName); + expect(pull.status, JSON.stringify(pull.body)).toBe(200); + expect(pull.body.candidateReady).toBe(true); + expect(pull.body.commitSha).toMatch(/^[0-9a-f]{40}$/); + + // Step 4: explicit revocation. The textarea is left empty, the UI + // sends remove_ca_bundle: true. The stored CA must be cleared. + const revokeRes = await page.evaluate(async ({ name, repoUrl }) => { + const res = await fetch(`/api/stacks/${name}/git-source`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + repo_url: repoUrl, + branch: 'main', + compose_paths: ['compose.yaml'], + auth_type: 'none', + remove_ca_bundle: true, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + }), + }); + return { status: res.status, body: await res.json() }; + }, { name: stackName, repoUrl }); + expect(revokeRes.status).toBe(200); + expect(revokeRes.body.has_ca_bundle).toBe(false); + + // Step 5: GET confirms the row no longer carries a CA bundle. + const afterRes = await page.evaluate(async (name) => { + const res = await fetch(`/api/stacks/${name}/git-source`, { credentials: 'include' }); + return { status: res.status, body: await res.json() }; + }, stackName); + expect(afterRes.status).toBe(200); + expect(afterRes.body.has_ca_bundle).toBe(false); + + // Step 6: with the stored CA gone, the same fetch must now fail on + // certificate trust. Nothing else in the environment trusts this + // fixture's CA, so a success here would mean the per-source bundle was + // never what authorised the earlier fetch. + const afterPull = await page.evaluate(async (name) => { + const res = await fetch(`/api/stacks/${name}/git-source/pull`, { + method: 'POST', + credentials: 'include', + }); + return { status: res.status, body: await res.json() }; + }, stackName); + expect(afterPull.status, JSON.stringify(afterPull.body)).not.toBe(200); + expect(JSON.stringify(afterPull.body)).toContain('TLS certificate error reaching'); + }); + + test('API rejects a non-PEM ca_bundle with 400', async ({ page }) => { + await loginAs(page); + await page.evaluate(async (name) => { + const res = await fetch('/api/stacks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ stackName: name }), + }); + if (res.status !== 200) throw new Error(`create stack failed: ${res.status}`); + }, stackName); + + const repoUrl = `${server.url}/app.git`; + const reject = await page.evaluate(async ({ name, repoUrl }) => { + const res = await fetch(`/api/stacks/${name}/git-source`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + repo_url: repoUrl, + branch: 'main', + compose_paths: ['compose.yaml'], + auth_type: 'none', + ca_bundle: 'not a certificate at all', + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + }), + }); + return { status: res.status, body: await res.json() }; + }, { name: stackName, repoUrl }); + expect(reject.status).toBe(400); + expect(String(reject.body.error || '')).toMatch(/PEM|certificate/i); + }); +}); diff --git a/e2e/gitServer.helper.ts b/e2e/gitServer.helper.ts index 5c263e06..900adac2 100644 --- a/e2e/gitServer.helper.ts +++ b/e2e/gitServer.helper.ts @@ -12,17 +12,18 @@ * NODE_EXTRA_CA_CERTS (wired in CI and in the local validation lifecycle). * The key is a throwaway test certificate with no security value. * - * Soft-skips when the system git binary is unavailable. + * Soft-skips when the system git binary is unavailable (locally; throws + * under CI, see externalDeps.ts). */ import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; import https from 'https'; import os from 'os'; import path from 'path'; +import { requireGitBinary } from './externalDeps'; export function gitAvailable(): boolean { - const probe = spawnSync('git', ['--version'], { stdio: 'ignore' }); - return probe.status === 0; + return requireGitBinary(); } /** Build a git repository with the given files on `branch`, returns the repo dir. */ @@ -49,7 +50,17 @@ export function buildFixtureRepo(files: Record, branch = 'main') * Serve the given repos (keyed by served name) over smart HTTPS. Returns the * base URL; repos are reachable at `/.git`. */ -export function serveRepos(repoDirs: Record): Promise<{ url: string; close: () => void }> { +export function serveRepos( + repoDirs: Record, + /** + * Basename (without extension) of the certificate pair under e2e/fixtures to + * present. Defaults to the shared dev CA that the app also trusts globally. + * The per-source CA spec passes a pair signed by a CA that is deliberately + * absent from process-wide trust, so that only a stored per-source bundle + * can make its fetch succeed. + */ + certBasename = 'git-server', +): Promise<{ url: string; close: () => void }> { return new Promise((resolve, reject) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-e2e-git-')); for (const [name, dir] of Object.entries(repoDirs)) { @@ -62,8 +73,8 @@ export function serveRepos(repoDirs: Record): Promise<{ url: str const fixtures = path.join(process.cwd(), 'e2e', 'fixtures'); const server = https.createServer( { - cert: fs.readFileSync(path.join(fixtures, 'git-server.pem')), - key: fs.readFileSync(path.join(fixtures, 'git-server.key')), + cert: fs.readFileSync(path.join(fixtures, `${certBasename}.pem`)), + key: fs.readFileSync(path.join(fixtures, `${certBasename}.key`)), }, (req, res) => { const url = req.url ?? '/'; diff --git a/e2e/nodes.spec.ts b/e2e/nodes.spec.ts index 4839b617..cf21a075 100644 --- a/e2e/nodes.spec.ts +++ b/e2e/nodes.spec.ts @@ -49,7 +49,7 @@ test.describe('Node management', () => { // Scope to the dialog so we target the submit button, not the trigger await page.getByRole('dialog').getByRole('button', { name: /add node/i }).click(); - await expect(page.getByText(/loopback|localhost/i)).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText(/target is not allowed/i)).toBeVisible({ timeout: 5_000 }); }); test('adding a node with an invalid URL shows an error', async ({ page }) => { diff --git a/e2e/sshGit.helper.ts b/e2e/sshGit.helper.ts index 405841f9..f4ff4f7f 100644 --- a/e2e/sshGit.helper.ts +++ b/e2e/sshGit.helper.ts @@ -10,10 +10,10 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import net from 'net'; import os from 'os'; import path from 'path'; +import { requireGitBinary, requireSshd } from './externalDeps'; export function sshGitFixtureAvailable(): boolean { - return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0 - && spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0; + return requireGitBinary() && requireSshd(); } const COMPOSE_FIXTURE = `services: diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 48ab8143..d3889b84 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -37,4 +37,14 @@ export default defineConfig([ 'react-hooks/gating': 'warn', }, }, + { + // Vendored shadcn/ui components; these export a helper constant (e.g. + // buttonVariants) alongside the component, which eslint-plugin-react-refresh + // 0.5.5 now correctly flags. Downgrade to warn rather than restructure + // generated files. + files: ['src/components/ui/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'warn', + }, + }, ]) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9f849e8b..7c612700 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,7 @@ "@xterm/addon-search": "^0.16.0", "@xterm/addon-serialize": "^0.14.0", "@xterm/xterm": "^6.0.0", - "@xyflow/react": "^12.11.3", + "@xyflow/react": "^12.11.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -38,9 +38,9 @@ "date-fns": "^4.4.0", "fflate": "^0.8.2", "geist": "^1.7.2", - "lucide-react": "^1.31.0", + "lucide-react": "^1.37.0", "monaco-editor": "^0.56.0", - "motion": "^13.1.0", + "motion": "^13.1.1", "qrcode.react": "^4.2.0", "radix-ui": "^1.6.7", "react": "^19.2.8", @@ -60,23 +60,23 @@ "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.4", - "@types/node": "^26.2.0", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", + "@types/node": "^26.4.0", "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.0.5", - "eslint": "^10.8.1", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.9.1", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.4", + "eslint-plugin-react-refresh": "^0.5.5", "globals": "^17.11.0", "jsdom": "^30.0.1", "rollup-plugin-visualizer": "^7.1.1", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", - "typescript-eslint": "^8.67.0", - "vite": "^8.2.1", - "vitest": "^4.1.10" + "typescript-eslint": "^8.68.0", + "vite": "^8.2.2", + "vitest": "^4.1.11" }, "engines": { "node": ">=26.0.0" @@ -876,9 +876,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.143.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", - "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", "dev": true, "license": "MIT", "funding": { @@ -2407,10 +2407,27 @@ } } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", - "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", "cpu": [ "arm64" ], @@ -2425,9 +2442,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", "cpu": [ "arm64" ], @@ -2442,9 +2459,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", "cpu": [ "x64" ], @@ -2459,9 +2476,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", - "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", "cpu": [ "x64" ], @@ -2476,9 +2493,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", - "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", "cpu": [ "arm" ], @@ -2493,9 +2510,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", - "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", "cpu": [ "arm64" ], @@ -2513,9 +2530,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", - "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", "cpu": [ "arm64" ], @@ -2533,9 +2550,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", - "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", "cpu": [ "ppc64" ], @@ -2553,9 +2570,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", - "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", "cpu": [ "s390x" ], @@ -2573,9 +2590,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", - "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", "cpu": [ "x64" ], @@ -2593,9 +2610,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", - "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", "cpu": [ "x64" ], @@ -2613,9 +2630,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", - "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", "cpu": [ "arm64" ], @@ -2630,9 +2647,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", - "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", "cpu": [ "arm64" ], @@ -2647,9 +2664,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", - "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", "cpu": [ "x64" ], @@ -3087,9 +3104,9 @@ } }, "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dev": true, "license": "MIT", "dependencies": { @@ -3115,9 +3132,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.4", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.4.tgz", - "integrity": "sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==", + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", "dev": true, "license": "MIT", "engines": { @@ -3313,9 +3330,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3333,9 +3350,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3362,17 +3379,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", + "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/type-utils": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -3385,7 +3402,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -3401,16 +3418,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "engines": { @@ -3426,14 +3443,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", "debug": "^4.4.3" }, "engines": { @@ -3448,14 +3465,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3466,9 +3483,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", "dev": true, "license": "MIT", "engines": { @@ -3483,15 +3500,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", + "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3508,9 +3525,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", "dev": true, "license": "MIT", "engines": { @@ -3522,16 +3539,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3563,16 +3580,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", + "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3587,13 +3604,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3611,9 +3628,9 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", - "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3625,6 +3642,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -3633,20 +3651,23 @@ }, "babel-plugin-react-compiler": { "optional": true + }, + "oxc-transform-react": { + "optional": true } } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3655,13 +3676,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3682,9 +3703,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -3695,13 +3716,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3709,14 +3730,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3725,9 +3746,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3735,13 +3756,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3777,12 +3798,12 @@ ] }, "node_modules/@xyflow/react": { - "version": "12.11.3", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.3.tgz", - "integrity": "sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==", + "version": "12.11.5", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.5.tgz", + "integrity": "sha512-QqoryGkqEhWBuQN9bZWRKhwr3Uoj9lCGj/tg0NnIWHHEOXV+5c8cYsc7Q9TST63V92lHqcNV9P5cjvJ9ZAmblQ==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.80", + "@xyflow/system": "0.0.81", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -3802,9 +3823,9 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.80", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.80.tgz", - "integrity": "sha512-ywc3ZqG91brzWrH1WlwMdIX4goOfrpBy6AbLdVSaof/Xx9l138ijIKRExM6EkMro2F+OImGmSiA/WKcXvKVcfA==", + "version": "0.0.81", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.81.tgz", + "integrity": "sha512-hfbafW4i7uLq7ILok8QWFFm4KMFw22lbZNJHKfHOMSOOoCk5e5m8yfr84UV9NaJajmogWaLVnp2XFU9JQejlqg==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -3924,9 +3945,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3970,9 +3991,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -3990,11 +4011,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -4020,9 +4041,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -4604,9 +4625,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.383", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz", - "integrity": "sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==", + "version": "1.5.419", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.419.tgz", + "integrity": "sha512-nHMPn8x4yCxCI0iSnL+LlHL5sUoUfjLXkcRIagZ4GBdrfFLFaiLNvzJWbJqZhFT9IAhw5tUSNlhggWN+otvp/A==", "dev": true, "license": "ISC" }, @@ -4685,9 +4706,9 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", "workspaces": [ @@ -4764,9 +4785,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.4.tgz", - "integrity": "sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.5.tgz", + "integrity": "sha512-vG7yLURXNvCHy0FBdbZRwIu0BLPJMlUUJS2Ep7ud9w1YCLftFZtuEjyjhym0Qq9yuZ6LJUitNlu/hMk0gakXAw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5008,12 +5029,12 @@ "license": "ISC" }, "node_modules/framer-motion": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.0.tgz", - "integrity": "sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.1.tgz", + "integrity": "sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA==", "license": "MIT", "dependencies": { - "motion-dom": "^13.0.0", + "motion-dom": "^13.1.1", "motion-utils": "^13.0.0", "tslib": "^2.4.0" }, @@ -5873,9 +5894,9 @@ } }, "node_modules/lucide-react": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", - "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.37.0.tgz", + "integrity": "sha512-LPsB4rD1TD6wZu1djKOf9vUnS1jTNaHbolXebXDgiTdb6jeA1agIJhJsIybCmjKmQClcOaal1o1OaiYahEftyQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6812,12 +6833,12 @@ } }, "node_modules/motion": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-13.1.0.tgz", - "integrity": "sha512-qtvscq59uCPdWnNW4SdSkrxR+BS/QYsa923bx7ocA+4p+ZGNbbVQwkSnG4aukB81QWjtl3AxX36plxNyZLmHCA==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-13.1.1.tgz", + "integrity": "sha512-WNZoK6xiF+kkTqkZ5K7FDDh6A8BG4i5Hc7KXtW8gtTxkpJFds+hIOrDaQGKjQj/AE/i4hJqAaUHEqp/Qo02y6Q==", "license": "MIT", "dependencies": { - "framer-motion": "^13.1.0", + "framer-motion": "^13.1.1", "tslib": "^2.4.0" }, "peerDependencies": { @@ -6834,9 +6855,9 @@ } }, "node_modules/motion-dom": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.0.0.tgz", - "integrity": "sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.1.1.tgz", + "integrity": "sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA==", "license": "MIT", "dependencies": { "motion-utils": "^13.0.0" @@ -6881,9 +6902,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -7058,9 +7079,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -7078,7 +7099,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7566,13 +7587,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", - "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.143.0", + "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7582,20 +7603,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.3", - "@rolldown/binding-darwin-arm64": "1.2.3", - "@rolldown/binding-darwin-x64": "1.2.3", - "@rolldown/binding-freebsd-x64": "1.2.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", - "@rolldown/binding-linux-arm64-gnu": "1.2.3", - "@rolldown/binding-linux-arm64-musl": "1.2.3", - "@rolldown/binding-linux-ppc64-gnu": "1.2.3", - "@rolldown/binding-linux-s390x-gnu": "1.2.3", - "@rolldown/binding-linux-x64-gnu": "1.2.3", - "@rolldown/binding-linux-x64-musl": "1.2.3", - "@rolldown/binding-openharmony-arm64": "1.2.3", - "@rolldown/binding-win32-arm64-msvc": "1.2.3", - "@rolldown/binding-win32-x64-msvc": "1.2.3" + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" } }, "node_modules/rollup-plugin-visualizer": { @@ -7930,9 +7952,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -8052,16 +8074,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", - "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", + "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.67.0", - "@typescript-eslint/parser": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0" + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8180,9 +8202,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -8323,16 +8345,16 @@ } }, "node_modules/vite": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", - "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.25", - "rolldown": "~1.2.1", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -8349,7 +8371,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8674,19 +8696,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8714,12 +8736,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/frontend/package.json b/frontend/package.json index e77213be..d1d4c9ee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,7 +37,7 @@ "@xterm/addon-search": "^0.16.0", "@xterm/addon-serialize": "^0.14.0", "@xterm/xterm": "^6.0.0", - "@xyflow/react": "^12.11.3", + "@xyflow/react": "^12.11.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -45,9 +45,9 @@ "date-fns": "^4.4.0", "fflate": "^0.8.2", "geist": "^1.7.2", - "lucide-react": "^1.31.0", + "lucide-react": "^1.37.0", "monaco-editor": "^0.56.0", - "motion": "^13.1.0", + "motion": "^13.1.1", "qrcode.react": "^4.2.0", "radix-ui": "^1.6.7", "react": "^19.2.8", @@ -72,22 +72,22 @@ "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.4", - "@types/node": "^26.2.0", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", + "@types/node": "^26.4.0", "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.0.5", - "eslint": "^10.8.1", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.9.1", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.4", + "eslint-plugin-react-refresh": "^0.5.5", "globals": "^17.11.0", "jsdom": "^30.0.1", "rollup-plugin-visualizer": "^7.1.1", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", - "typescript-eslint": "^8.67.0", - "vite": "^8.2.1", - "vitest": "^4.1.10" + "typescript-eslint": "^8.68.0", + "vite": "^8.2.2", + "vitest": "^4.1.11" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 461b5779..41521525 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from './context/AuthContext'; import { useReducedMotion } from './hooks/use-theme'; import { NodeProvider } from './context/NodeContext'; import { LicenseProvider } from './context/LicenseContext'; +import { BuildInfoProvider } from './context/BuildInfoProvider'; import { Login } from './components/Login'; import { Setup } from './components/Setup'; import EditorLayout from './components/EditorLayout'; @@ -67,11 +68,13 @@ function AppContent() { )} - - {/* Portal lives inside LicenseProvider so the editor surface and its - portalled overlays can read license state via useLicense(). - Outer DeployFeedbackProvider is still an ancestor through App. */} - + + + {/* Portal lives inside LicenseProvider so the editor surface and its + portalled overlays can read license state via useLicense(). + Outer DeployFeedbackProvider is still an ancestor through App. */} + + diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index d39bdaa4..0284d3fb 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -22,6 +22,7 @@ import { useOverlayState } from './EditorLayout/hooks/useOverlayState'; import { useStackActions, NODE_SWITCH_PENDING_TOKEN } from './EditorLayout/hooks/useStackActions'; import { useSelectedStackLiveRefresh } from './EditorLayout/hooks/useSelectedStackLiveRefresh'; import { useTheme } from '@/hooks/use-theme'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch'; import { useNotifications } from './EditorLayout/hooks/useNotifications'; import { useContainerStats } from './EditorLayout/hooks/useContainerStats'; @@ -247,7 +248,6 @@ export default function EditorLayout() { const [topNavLabels] = useTopNavLabels(); const [topNavAlign] = useTopNavAlign(); const [topNavMode] = useTopNavMode(); - const { persistedIds: quickLinkIds, addQuickLink, removeQuickLink } = useTopNavQuickLinks(); // Use a ref to break the circular dependency: // useViewNavigationState needs onNavigateToDashboard -> resetEditorState @@ -280,8 +280,13 @@ export default function EditorLayout() { navModel, openMuteRulesWithPrefill, reachCtx, + defaultQuickLinkEligibility, } = navState; + // Called after navState so it can be seeded from navState.defaultQuickLinkEligibility + // (settled, role- and capability-aware defaults) rather than the raw recommended list. + const { persistedIds: quickLinkIds, addQuickLink, removeQuickLink } = useTopNavQuickLinks(defaultQuickLinkEligibility); + const visibleQuickLinks = useMemo(() => { const candidateSet = new Set(navModel.quickLinkCandidates.map((item) => item.value)); return quickLinkIds @@ -449,6 +454,7 @@ export default function EditorLayout() { const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill); const { isDarkMode } = useTheme(); + const { buildInfo } = useBuildInfo(); // ---- Mobile shell (below md) --------------------------------------------- // Desktop renders the persistent sidebar + workspace untouched. On a phone we @@ -970,6 +976,7 @@ export default function EditorLayout() { const sidebarEl = ( openSettings('nodes')} @@ -1125,6 +1132,7 @@ export default function EditorLayout() { urlHydratingStack={urlHydratingStack} isFileLoading={isFileLoading} quickLinkCandidates={navModel.quickLinkCandidates} + defaultQuickLinkEligibility={defaultQuickLinkEligibility} /> ); @@ -1204,6 +1212,7 @@ export default function EditorLayout() { selectedSection={mobileSettingsSection} onSelectedSectionChange={setMobileSettingsSection} quickLinkCandidates={navModel.quickLinkCandidates} + defaultQuickLinkEligibility={defaultQuickLinkEligibility} /> ); case 'security': diff --git a/frontend/src/components/EditorLayout/CreateStackDialog.tsx b/frontend/src/components/EditorLayout/CreateStackDialog.tsx index cc721c9a..441594c0 100644 --- a/frontend/src/components/EditorLayout/CreateStackDialog.tsx +++ b/frontend/src/components/EditorLayout/CreateStackDialog.tsx @@ -73,6 +73,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks const [gitAuthType, setGitAuthType] = useState<'none' | 'token' | 'deploy_key'>('none'); const [gitToken, setGitToken] = useState(''); const [gitDeployKey, setGitDeployKey] = useState(''); + const [gitCaBundle, setGitCaBundle] = useState(''); const [gitSshKnownHostsEntry, setGitSshKnownHostsEntry] = useState(''); const [gitSshHostKeyFingerprint, setGitSshHostKeyFingerprint] = useState(''); const [gitApplyMode, setGitApplyMode] = useState('review'); @@ -90,6 +91,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks setGitAuthType('none'); setGitToken(''); setGitDeployKey(''); + setGitCaBundle(''); setGitSshKnownHostsEntry(''); setGitSshHostKeyFingerprint(''); setGitApplyMode('review'); @@ -113,6 +115,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks if (gitDeployKey !== '') body.deploy_key = gitDeployKey; if (gitSshKnownHostsEntry !== '') body.ssh_known_hosts_entry = gitSshKnownHostsEntry; } + if (gitCaBundle !== '') body.ca_bundle = gitCaBundle; const res = await apiFetch('/git-sources/browse', { method: 'POST', body: JSON.stringify(body), @@ -224,6 +227,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks body.ssh_known_hosts_entry = gitSshKnownHostsEntry; body.ssh_host_key_fingerprint = gitSshHostKeyFingerprint; } + if (gitCaBundle !== '') body.ca_bundle = gitCaBundle; const response = await apiFetch('/stacks/from-git', { method: 'POST', body: JSON.stringify(body), @@ -469,10 +473,13 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks authType={gitAuthType} token={gitToken} deployKey={gitDeployKey} + caBundle={gitCaBundle} sshKnownHostsEntry={gitSshKnownHostsEntry} sshHostKeyFingerprint={gitSshHostKeyFingerprint} hasStoredToken={false} hasStoredDeployKey={false} + hasStoredCaBundle={false} + removeCaBundle={false} storedHostKeyFingerprint={null} applyMode={gitApplyMode} onRepoUrlChange={setGitRepoUrl} @@ -483,6 +490,11 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks onAuthTypeChange={setGitAuthType} onTokenChange={setGitToken} onDeployKeyChange={setGitDeployKey} + onCaBundleChange={setGitCaBundle} + onRemoveCaBundle={() => { + /* No stored CA in the create flow; the prop is required + * so the same component can be reused. */ + }} onSshKnownHostsEntryChange={setGitSshKnownHostsEntry} onSshHostKeyFingerprintChange={setGitSshHostKeyFingerprint} onApplyModeChange={setGitApplyMode} diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index ff147b99..7703fd15 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -114,6 +114,7 @@ export interface ViewRouterProps { urlHydratingStack: string | null; isFileLoading: boolean; quickLinkCandidates?: NavDestination[]; + defaultQuickLinkEligibility?: ActiveView[] | null; } export function ViewRouter({ @@ -148,6 +149,7 @@ export function ViewRouter({ urlHydratingStack, isFileLoading, quickLinkCandidates, + defaultQuickLinkEligibility, }: ViewRouterProps): ReactNode { const { can, permissionsStatus } = useAuth(); const { isPaid, licenseReady } = useLicense(); @@ -161,6 +163,7 @@ export function ViewRouter({ onMutePrefillConsumed={onMutePrefillConsumed} onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} quickLinkCandidates={quickLinkCandidates} + defaultQuickLinkEligibility={defaultQuickLinkEligibility} /> ); } diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx index c437817e..797a2ed0 100644 --- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -391,6 +391,51 @@ describe('useViewNavigationState', () => { expect(values).toContain('audit-log'); }); + // ── defaultQuickLinkEligibility: settled default eligibility for quick links ─ + + it('defaultQuickLinkEligibility is null while permissions are still loading', () => { + mockAuth(true, () => true, 'loading'); + mockLicense(true, 'ready'); + const { result } = renderHook(() => useViewNavigationState()); + expect(result.current.defaultQuickLinkEligibility).toBeNull(); + }); + + it('defaultQuickLinkEligibility is null while license status is still loading', () => { + mockAuth(true, () => true, 'ready'); + mockLicense(true, 'loading'); + const { result } = renderHook(() => useViewNavigationState()); + expect(result.current.defaultQuickLinkEligibility).toBeNull(); + }); + + it('defaultQuickLinkEligibility is a non-null, role-filtered list once settled', () => { + mockPaidAdmin(); + const { result } = renderHook(() => useViewNavigationState()); + expect(result.current.defaultQuickLinkEligibility).toEqual([ + 'dashboard', 'fleet', 'resources', 'security', 'auto-updates', 'scheduled-ops', + ]); + }); + + it('excludes admin-only and scheduling-gated defaults for a non-admin community user', () => { + // mockCommunityUser grants node:read (so Fleet, which is gated on that permission + // alone, stays included) but not admin or any scheduling-capable permission. + mockCommunityUser(); + const { result } = renderHook(() => useViewNavigationState()); + expect(result.current.defaultQuickLinkEligibility).toEqual(['dashboard', 'fleet', 'resources', 'security']); + }); + + it('still includes hub-only defaults on a remote node, unlike the display-time navItems list', () => { + mockPaidAdmin(); + mockActiveNode('remote'); + const { result } = renderHook(() => useViewNavigationState()); + // Contrast with the "hides hub-only views" test above: navItems (display) drops + // these on a remote node, but defaultQuickLinkEligibility must not, since + // recommended defaults reflect the operator's role, not the active node tab. + expect(result.current.navItems.map(i => i.value)).not.toContain('fleet'); + expect(result.current.defaultQuickLinkEligibility).toEqual([ + 'dashboard', 'fleet', 'resources', 'security', 'auto-updates', 'scheduled-ops', + ]); + }); + // ── auto-redirect when on a hub-only view and node switches to remote ────── it('auto-redirects to dashboard when active view is hub-only and node becomes remote', () => { diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts index 9bf7dab7..f3f8ee0f 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -46,6 +46,12 @@ export type LoadFileOptions = { // buffers have been reverted via setters that have not re-rendered yet, so // hasUnsavedChanges() would still see the pre-discard values. skipUnsavedCheck?: boolean; + // Pin the whole load chain (compose, env, backup, containers, services) to + // this node. A number targets that node; null targets the local node; + // undefined follows the active node. loadFileOnNode always sets it so a load + // started for node N cannot retarget to whatever node another tab made + // active mid-request. + nodeId?: number | null; }; export function useOverlayState() { diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index dfd71135..c13fe870 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -152,6 +152,7 @@ function setup(over: { removeNotificationsForStack?: (nodeId: number, stackName: string) => void; isAdmin?: boolean; canReapplyCompose?: boolean; + hasServiceScopedUpdate?: boolean; } = {}) { const editorState = makeEditorState(over.editorState); const stackListState = makeStackListState(over.stackList); @@ -185,6 +186,7 @@ function setup(over: { diffPreviewEnabled: false, hasUpdateGuard: over.hasUpdateGuard ?? false, hasGuidedExternalNetworkPreflight: over.hasGuidedExternalNetworkPreflight ?? false, + hasServiceScopedUpdate: over.hasServiceScopedUpdate ?? false, canEditStack: over.canEditStack ?? (() => true), onDeletedOpenStack, removeNotificationsForStack, @@ -392,6 +394,272 @@ describe('useStackActions node binding', () => { }); }); +// Node-pin regression for the compose/env editor chain (#1854): every request a +// loadFile -> edit -> saveFile operation issues must target the node the tab +// captured, never the localStorage value some other tab rewrote mid-session. +// The suite mocks apiFetch, so these assert the explicit nodeId CALL OPTION; +// the option-to-header mapping is proven in src/lib/__tests__/api.test.ts +// ("apiFetch nodeId override"), and browser-level integration by the two-tab +// e2e in e2e/editor-save-deploy.spec.ts. +describe('useStackActions editor request node pinning', () => { + // URL-keyed dispatcher: order-independent, unlike mockResolvedValueOnce + // chains that silently desynchronize when the chain changes. + function mockEditorChain(overrides: { put?: Response } = {}) { + vi.mocked(apiFetch).mockImplementation((url: string, init?: RequestInit) => { + const u = String(url); + const method = init?.method ?? 'GET'; + if (u === '/stacks/web.yml' && method === 'GET') { + return Promise.resolve(new Response('services: {}', { status: 200 })); + } + if ((u === '/stacks/web.yml' || u.includes('/stacks/web.yml/env?file=')) && method === 'PUT') { + // A forced retry (no If-Match) always succeeds; the server never + // answers a forced PUT with 412, and an unconditional 412 here would + // recurse saveFile forever. + const hasIfMatch = !!(init?.headers as Record | undefined)?.['If-Match']; + if (overrides.put && hasIfMatch) return Promise.resolve(overrides.put); + return Promise.resolve(new Response(null, { status: 200 })); + } + if (u === '/stacks/web.yml/envs') { + return Promise.resolve(new Response(JSON.stringify({ envFiles: ['.env'] }), { status: 200 })); + } + if (u.startsWith('/stacks/web.yml/env?file=')) { + return Promise.resolve(new Response('KEY=value', { status: 200 })); + } + if (u === '/stacks/web/containers') { + return Promise.resolve(new Response('[]', { status: 200 })); + } + if (u === '/stacks/web.yml/backup') { + return Promise.resolve(new Response(JSON.stringify({ exists: false }), { status: 200 })); + } + if (u === '/stacks/web/effective-services') { + return Promise.resolve(new Response(JSON.stringify({ renderable: false, services: [] }), { status: 200 })); + } + return Promise.resolve(new Response('', { status: 404 })); + }); + } + + function callFor(fragment: string, method = 'GET') { + const call = vi.mocked(apiFetch).mock.calls.find( + c => String(c[0]).includes(fragment) && ((c[1] as RequestInit | undefined)?.method ?? 'GET') === method, + ); + expect(call, `expected a ${method} call matching ${fragment}`).toBeDefined(); + return (call![1] ?? {}) as RequestInit & { nodeId?: number | null }; + } + + beforeEach(() => { + vi.mocked(apiFetch).mockReset(); + lastRunWithLogParams = null; + }); + + it('saveFile PUT carries the captured active node id', async () => { + mockEditorChain(); + const { result } = setup(); + const ok = await result.current.saveFile(); + expect(ok).toBe(true); + expect(callFor('/stacks/web.yml', 'PUT')).toEqual(expect.objectContaining({ nodeId: 1 })); + }); + + it('loadFile pins every hydration GET to the captured node', async () => { + mockEditorChain(); + // Equal content/originalContent buffers make the fixture a pure load with + // no dirty-state interaction. + const { result } = setup({ + hasServiceScopedUpdate: true, + editorState: { content: 'same', originalContent: 'same' }, + }); + await result.current.loadFile('web.yml'); + const expected = [ + '/stacks/web.yml', // compose GET + '/stacks/web.yml/envs', // env list + '/stacks/web.yml/env?file=', // env content + '/stacks/web/containers', // container list + '/stacks/web.yml/backup', // backup info + '/stacks/web/effective-services', + ]; + for (const fragment of expected) { + expect(callFor(fragment)).toEqual(expect.objectContaining({ nodeId: 1 })); + } + const other = vi.mocked(apiFetch).mock.calls.filter( + c => ((c[1] as RequestInit & { nodeId?: number | null } | undefined)?.nodeId ?? null) !== 1, + ); + expect(other, 'no request in the load chain may carry a different node').toHaveLength(0); + }); + + it('changeEnvFile GET carries the captured node id', async () => { + mockEditorChain(); + const { result } = setup(); + await result.current.changeEnvFile('.env'); + expect(callFor('/stacks/web.yml/env?file=', 'GET')).toEqual( + expect.objectContaining({ nodeId: 1 }), + ); + }); + + it('env saveFile PUT carries the captured node id', async () => { + mockEditorChain(); + const { result } = setup({ + editorState: { activeTab: 'env', selectedEnvFile: '.env', envContent: 'K=v', originalEnvContent: 'K=v' }, + }); + const ok = await result.current.saveFile(); + expect(ok).toBe(true); + expect(callFor('/stacks/web.yml/env?file=', 'PUT')).toEqual(expect.objectContaining({ nodeId: 1 })); + }); + + it('the 412 overwrite retry reuses the same captured node id on both PUTs', async () => { + mockEditorChain({ + put: new Response( + JSON.stringify({ currentContent: 'remote edit' }), + { status: 412, headers: { 'Content-Type': 'application/json' } }, + ), + }); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + try { + // composeEtag gives the initial PUT an If-Match header, which is what the + // server answers 412 to; the forced retry sends no If-Match and succeeds. + const { result } = setup({ editorState: { composeEtag: '"etag-1"' } }); + const ok = await result.current.saveFile(); + expect(ok).toBe(true); + const puts = vi.mocked(apiFetch).mock.calls.filter( + c => String(c[0]) === '/stacks/web.yml' && (c[1] as RequestInit | undefined)?.method === 'PUT', + ); + expect(puts).toHaveLength(2); // initial + force retry + const nodeIds = puts.map( + c => (c[1] as RequestInit & { nodeId?: number | null }).nodeId, + ); + expect(nodeIds).toEqual([1, 1]); + } finally { + confirmSpy.mockRestore(); + } + }); + + it('loadFileOnNode pins the whole chain to the target node and survives a rerender mid-load', async () => { + // The env-chain GETs are held until the component has re-rendered with the + // new node active, so the containers fetch is already armed with the load + // target when the operator's context settles. A live-ref expectation (the + // pre-fix behavior) would then mark the target node's response stale; the + // pin must keep it accepted. + let releaseChain: ((r: Response) => void) | null = null; + const held = (): Promise => + new Promise((resolve) => { releaseChain = resolve; }); + vi.mocked(apiFetch).mockImplementation((url: string, init?: RequestInit) => { + const u = String(url); + const method = init?.method ?? 'GET'; + if (u === '/stacks/webapp.yml' && method === 'GET') { + return Promise.resolve(new Response('services: {}', { status: 200 })); + } + if (u === '/stacks/webapp.yml/envs' || u === '/stacks/webapp/containers') { + return held(); + } + if (u === '/stacks/webapp.yml/backup') { + return Promise.resolve(new Response(JSON.stringify({ exists: false }), { status: 200 })); + } + if (u === '/stacks/webapp/effective-services') { + return Promise.resolve(new Response(JSON.stringify({ renderable: false, services: [] }), { status: 200 })); + } + return Promise.resolve(new Response('', { status: 404 })); + }); + + const targetNode = { id: 2, type: 'remote' } as Parameters[0]['activeNode']; + // Equal content buffers keep the fixture a pure load with no dirty-state + // interaction (the stackList mock starts with no file selected, so the + // unsaved-changes gate is not even reached). + // The stackList mock must track setSelectedFile so the ownership ref + // (synced from stackListState.selectedFile each render) sees the file the + // load claimed; a frozen null would mark every mid-flight fetch stale. + const live = setup({ + hasServiceScopedUpdate: true, + editorState: { content: 'same', originalContent: 'same' }, + stackList: { selectedFile: null }, + }); + (live.stackListState.setSelectedFile as unknown as { mockImplementation: (fn: (f: string | null) => void) => void }) + .mockImplementation((f: string | null) => { + (live.stackListState as unknown as { selectedFile: string | null }).selectedFile = f; + }); + const { result, rerender, activeNodeHolder, editorState } = live; + + let loadPromise!: Promise; + act(() => { + loadPromise = result.current.loadFileOnNode(targetNode!, 'webapp.yml'); + }); + // loadFileOnNode captured the target before any re-render. + expect(callFor('/stacks/webapp.yml')).toEqual(expect.objectContaining({ nodeId: 2 })); + + // The operator's context settles on the new node (rerender) while the + // envs GET is still in flight, so the containers expectation is built + // BEFORE the flip (from the captured pin), not after it. + activeNodeHolder.current = targetNode; + rerender(); + // Drain the microtask queue so the held envs GET is observed as in-flight. + await act(async () => { await Promise.resolve(); }); + expect(releaseChain).not.toBeNull(); + + await act(async () => { + releaseChain?.(new Response(JSON.stringify({ envFiles: [] }), { status: 200 })); + // Let the env chain settle (empty list) so the containers GET fires + // while the flipped context is live; its response is released next. + // Bounded drain instead of a fixed tick count: it exits as soon as the + // containers GET is observed and fails loudly if it never appears. + for (let i = 0; i < 100; i++) { + await Promise.resolve(); + if (vi.mocked(apiFetch).mock.calls.some( + c => String(c[0]) === '/stacks/webapp/containers', + )) break; + } + }); + expect(callFor('/stacks/webapp/containers')).toEqual(expect.objectContaining({ nodeId: 2 })); + + await act(async () => { + releaseChain?.(new Response(JSON.stringify([{ Id: 'c1', Names: ['/webapp'], State: 'running' }]), { status: 200 })); + await loadPromise; + }); + // Target-node containers were ACCEPTED (not rejected as stale). + expect(editorState.setContainers).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ Id: 'c1' })]), + ); + expect(editorState.setContainersLoadStatus).toHaveBeenCalledWith('success'); + + // Every request in the chain targeted node 2, and none targeted node 1. + const calls = vi.mocked(apiFetch).mock.calls; + const wrong = calls.filter( + c => ((c[1] as RequestInit & { nodeId?: number | null } | undefined)?.nodeId ?? null) !== 2, + ); + expect(wrong, 'every chained request must carry nodeId: 2').toHaveLength(0); + }); + + it('absent active node and absent expected node are the same local identity (no false stale)', async () => { + const setContainers = vi.fn(); + const setContainersLoadStatus = vi.fn(); + vi.mocked(apiFetch).mockResolvedValue( + new Response(JSON.stringify([{ Id: 'c1', Names: ['/web'], State: 'running' }]), { status: 200 }), + ); + const { result } = setup({ + activeNode: null, + editorState: { + containers: [], + containersLoadStatus: 'success', + containersLoadError: null, + setContainers, + setContainersLoadStatus, + setContainersLoadError: vi.fn(), + } as never, + }); + await act(async () => { + await result.current.refreshSelectedContainers('web', 'web.yml'); + // The Retry affordance derives its expectation the same way; a regression + // in either one-line normalization would false-stale on a local load. + await result.current.retryContainersLoad(); + }); + expect(apiFetch).toHaveBeenCalledWith( + '/stacks/web/containers', + expect.objectContaining({ nodeId: null }), + ); + expect(apiFetch).toHaveBeenCalledTimes(2); + expect(setContainers).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ Id: 'c1' })]), + ); + expect(setContainersLoadStatus).toHaveBeenCalledWith('success'); + }); +}); + describe('useStackActions policy-block dialog wiring', () => { const policyPayload = { error: 'Policy "block-high" blocked deploy: 1 image(s) exceed HIGH', diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 7fb49946..f5b48f13 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -636,7 +636,9 @@ export function useStackActions(options: UseStackActionsOptions) { signal?: AbortSignal; attemptId?: string; expectedFile: string; - expectedNodeId: number | undefined; + // The node the fetch was pinned to (null = local). A mid-flight node change + // elsewhere cannot invalidate (or retarget) it. + expectedNodeId: number | null; generation: number; }; @@ -645,7 +647,11 @@ export function useStackActions(options: UseStackActionsOptions) { ): 'ok' | 'aborted' | 'stale' => { if (ownership.signal?.aborted) return 'aborted'; if (selectedFileRef.current !== ownership.expectedFile) return 'stale'; - if (activeNodeIdRef.current !== ownership.expectedNodeId) return 'stale'; + // Both sides normalize against null: the ref is `number | undefined` (no + // active node yet) and the ownership field is `number | null` (pinned + // local target). `undefined !== null` is true in strict equality, so an + // un-normalized pair would report a false stale for a plain local load. + if ((activeNodeIdRef.current ?? null) !== ownership.expectedNodeId) return 'stale'; if (containersFetchGenRef.current !== ownership.generation) return 'stale'; if ( ownership.attemptId !== undefined @@ -766,7 +772,7 @@ export function useStackActions(options: UseStackActionsOptions) { if (selectedFileRef.current !== stackFile) return 'skipped'; const result = await fetchStackContainers(stackFile, 'soft', { expectedFile: stackFile, - expectedNodeId: activeNodeIdRef.current, + expectedNodeId: activeNodeIdRef.current ?? null, }); if (result.ok) return 'ok'; if (result.reason === 'stale' || result.reason === 'aborted') return 'skipped'; @@ -778,20 +784,23 @@ export function useStackActions(options: UseStackActionsOptions) { if (!stackFile) return; await fetchStackContainers(stackFile, 'foreground', { expectedFile: stackFile, - expectedNodeId: activeNodeIdRef.current, + expectedNodeId: activeNodeIdRef.current ?? null, }); }; const loadContainerState = ( filename: string, - signal?: AbortSignal, - attemptId?: string, + signal: AbortSignal | undefined, + attemptId: string | undefined, + // Pin the fetch to this node (null = local). Only loadFileCore passes it; + // the other callers construct the expectation from the current ref. + expectedNodeId: number | null, ): Promise => fetchStackContainers(filename, 'foreground', { signal, attemptId, expectedFile: filename, - expectedNodeId: activeNodeIdRef.current, + expectedNodeId, }); // Stack operations whose failure produces a recovery panel. A failed @@ -884,7 +893,10 @@ export function useStackActions(options: UseStackActionsOptions) { } setActiveNode(node); stackListState.setSearchQuery(''); - await loadFileRef.current(filename, options); + // Pin the load to the target node explicitly: activeNode has not re-rendered + // yet, so an unpinned load would read the stale ref (or, once it does render, + // whatever node another tab made active in the shared localStorage). + await loadFileRef.current(filename, { ...options, nodeId: node.id }); }; const clearEnvState = () => { @@ -896,9 +908,9 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setEnvEtag(null); }; - const loadEnvState = async (filename: string, signal?: AbortSignal): Promise => { + const loadEnvState = async (filename: string, signal?: AbortSignal, opNodeId?: number | null): Promise => { try { - const envsRes = await apiFetch(`/stacks/${filename}/envs`, { signal }); + const envsRes = await apiFetch(`/stacks/${filename}/envs`, { signal, nodeId: opNodeId }); if (signal?.aborted) return []; if (!envsRes.ok) { clearEnvState(); @@ -913,7 +925,7 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setEnvExists(true); const envContentRes = await apiFetch( `/stacks/${filename}/env?file=${encodeURIComponent(firstFile)}`, - { signal }, + { signal, nodeId: opNodeId }, ); if (signal?.aborted) return envFiles; if (envContentRes.ok) { @@ -937,9 +949,9 @@ export function useStackActions(options: UseStackActionsOptions) { } }; - const loadBackupState = async (filename: string, signal?: AbortSignal) => { + const loadBackupState = async (filename: string, signal?: AbortSignal, opNodeId?: number | null) => { try { - const backupRes = await apiFetch(`/stacks/${filename}/backup`, { signal }); + const backupRes = await apiFetch(`/stacks/${filename}/backup`, { signal, nodeId: opNodeId }); if (signal?.aborted) return; if (backupRes.ok) editorState.setBackupInfo(await backupRes.json()); else editorState.setBackupInfo({ exists: false, timestamp: null }); @@ -953,14 +965,14 @@ export function useStackActions(options: UseStackActionsOptions) { // without the capability so an older remote node never sees the extra // request; a render failure or non-ok response also fails closed to an // empty list, which keeps the legacy single-service layout. - const loadEffectiveServicesState = async (filename: string, signal?: AbortSignal) => { + const loadEffectiveServicesState = async (filename: string, signal?: AbortSignal, opNodeId?: number | null) => { if (!hasServiceScopedUpdate) { editorState.setEffectiveServices([]); return; } const stackName = filename.replace(/\.(yml|yaml)$/, ''); try { - const res = await apiFetch(`/stacks/${stackName}/effective-services`, { signal }); + const res = await apiFetch(`/stacks/${stackName}/effective-services`, { signal, nodeId: opNodeId }); if (signal?.aborted) return; if (!res.ok) { editorState.setEffectiveServices([]); @@ -1022,9 +1034,13 @@ export function useStackActions(options: UseStackActionsOptions) { containersFetchGenRef.current += 1; let headersSpan: SpanHandle | null = null; let bodySpan: SpanHandle | null = null; + // Node pin for the whole load: an explicit option wins (loadFileOnNode), + // otherwise the node this tab captured. Captured once here so no request in + // the chain can follow a node another tab made active mid-load. + const opNodeId = options?.nodeId !== undefined ? options.nodeId : (activeNode?.id ?? null); try { headersSpan = beginSpan('fetch_headers', { attemptId }); - const res = await apiFetch(`/stacks/${filename}`, { signal }); + const res = await apiFetch(`/stacks/${filename}`, { signal, nodeId: opNodeId }); const proxied = res.headers.get('x-sencho-proxy') === '1'; endSpan(headersSpan, { proxied, detail: { status: res.status } }); headersSpan = null; @@ -1046,8 +1062,8 @@ export function useStackActions(options: UseStackActionsOptions) { endSpan(dispatchSpan); detailVisiblePendingRef.current = { attemptId, token: filename, proxied }; setDetailVisibleEpoch((n) => n + 1); - const envFiles = await loadEnvState(filename, signal); - const containersResult = await loadContainerState(filename, signal, attemptId); + const envFiles = await loadEnvState(filename, signal, opNodeId); + const containersResult = await loadContainerState(filename, signal, attemptId, opNodeId); if (!signal.aborted) { if (containersResult.ok) { detailContainersPendingRef.current = { @@ -1064,8 +1080,8 @@ export function useStackActions(options: UseStackActionsOptions) { }); } } - await loadBackupState(filename, signal); - await loadEffectiveServicesState(filename, signal); + await loadBackupState(filename, signal, opNodeId); + await loadEffectiveServicesState(filename, signal, opNodeId); // Post-load auto-edit evaluates permission for the loaded target, not // the previously selected stack (selectedFile was just updated above). if (options?.startInComposeEdit && canEditStack(filename)) { @@ -1130,9 +1146,13 @@ export function useStackActions(options: UseStackActionsOptions) { const changeEnvFile = async (file: string) => { editorState.setSelectedEnvFile(file); editorState.setIsFileLoading(true); + // Pin the fetch to the node this tab captured; another tab switching the + // shared active node mid-edit must not retarget this GET. + const opNodeId = activeNode?.id ?? null; try { const res = await apiFetch( `/stacks/${stackListState.selectedFile}/env?file=${encodeURIComponent(file)}`, + { nodeId: opNodeId }, ); if (!res.ok) { editorState.setEnvContent(''); @@ -1170,11 +1190,16 @@ export function useStackActions(options: UseStackActionsOptions) { const etag = isCompose ? editorState.composeEtag : editorState.envEtag; const headers: Record = {}; if (!force && etag) headers['If-Match'] = etag; + // Pin the PUT to the node this tab captured at the start of the operation. + // A forced retry re-enters this same closure and reads the same already + // captured `activeNode` binding, so both PUTs carry one target. + const opNodeId = activeNode?.id ?? null; try { const response = await apiFetch(endpoint, { method: 'PUT', headers, body: JSON.stringify({ content: currentContent }), + nodeId: opNodeId, }); if (response.status === 412) { const payload = await response.json().catch(() => null); diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 75b1975e..eae77707 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -13,13 +13,14 @@ import { HUB_ONLY_VIEWS } from '@/lib/router/routeTypes'; import { readUrlRouteState } from '@/lib/router/readUrlRouteState'; import { authzReady, + isViewHidden, normalizeHiddenView, type ReachabilityContext, } from '@/lib/routing/reachability'; import { useExperimental } from '@/hooks/useExperimental'; import { canScheduleAny } from '@/lib/scheduledActions'; import { buildNavigationModel } from '@/lib/navigation/buildNavigationModel'; -import type { NavDestination } from '@/lib/navigation/appNavRegistry'; +import { recommendedQuickLinkIds, type NavDestination } from '@/lib/navigation/appNavRegistry'; export type { ActiveView }; export { HUB_ONLY_VIEWS }; @@ -128,6 +129,20 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) const navModel = useMemo(() => buildNavigationModel(reachCtx), [reachCtx]); const navItems = navModel.allPageItems; + // Settled default eligibility for quick-link seeding/reset: distinct from navModel's + // quickLinkCandidates (current-context, fail-open display filtering). Requires authzReady + // (role/license settled) before returning anything, so a still-loading permissions/license + // fetch never causes an incomplete default set to be seeded/persisted. isRemote is + // deliberately overridden to false: default eligibility reflects the operator's role, not + // which node tab happens to be open. Three of the six recommended defaults (fleet, + // auto-updates, scheduled-ops) are HUB_ONLY_VIEWS, and evaluating with the real isRemote + // would silently drop them whenever a remote node is active. + const defaultQuickLinkEligibility = useMemo(() => { + if (!authzReady(reachCtx)) return null; + const roleCtx: ReachabilityContext = { ...reachCtx, isRemote: false }; + return recommendedQuickLinkIds.filter((id) => !isViewHidden(id, roleCtx)); + }, [reachCtx]); + useEffect(() => { if (!authzReady(reachCtx)) return; const normalized = normalizeHiddenView(activeView, reachCtx); @@ -155,5 +170,6 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) navItems, navModel, reachCtx, + defaultQuickLinkEligibility, } as const; } diff --git a/frontend/src/components/MobileTabBar.test.tsx b/frontend/src/components/MobileTabBar.test.tsx index ad917026..162a9914 100644 --- a/frontend/src/components/MobileTabBar.test.tsx +++ b/frontend/src/components/MobileTabBar.test.tsx @@ -1,15 +1,37 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { Home, Radar, Clock } from 'lucide-react'; import { MobileTabBar } from './MobileTabBar'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; +import type { BuildInfo } from '@/context/BuildInfoProvider'; import type { NavItem } from './EditorLayout/hooks/useViewNavigationState'; +vi.mock('@/hooks/useBuildInfo', () => ({ + useBuildInfo: vi.fn(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() })), +})); + +const mockUseBuildInfo = vi.mocked(useBuildInfo); + const allItems: NavItem[] = [ { value: 'dashboard', label: 'Home', icon: Home }, { value: 'fleet', label: 'Fleet', icon: Radar }, { value: 'scheduled-ops', label: 'Schedules', icon: Clock }, ]; +function buildInfo(channel: BuildInfo['channel']): BuildInfo { + return { + version: '0.97.1', + channel, + imageChannel: 'community', + imageRef: channel === 'dev' ? 'ghcr.io/studio-saelix/sencho-dev:dev' : 'ghcr.io/studio-saelix/sencho:0.97.1', + imageId: 'a'.repeat(64), + revision: null, + restricted: false, + }; +} + +const noPill = { buildInfo: null, status: 'ready' as const, retry: vi.fn() }; + function renderBar(over: Partial> = {}) { const props: React.ComponentProps = { navItems: allItems, @@ -76,3 +98,52 @@ describe('MobileTabBar', () => { expect(screen.getByRole('button', { name: 'Stacks' })).not.toHaveAttribute('aria-current'); }); }); + +describe('MobileTabBar build-identity pill', () => { + beforeEach(() => { + mockUseBuildInfo.mockReturnValue(noPill); + }); + + it('shows a text DEV pill for a dev build, not an interactive control', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() }); + renderBar(); + expect(screen.getByText('DEV')).toBeInTheDocument(); + // The pill is a plain span: it adds no competing tap target in the tab row. + expect(screen.queryByRole('button', { name: 'DEV' })).not.toBeInTheDocument(); + }); + + it('shows a text PREVIEW pill for a preview build', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('preview'), status: 'ready', retry: vi.fn() }); + renderBar(); + expect(screen.getByText('PREVIEW')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'PREVIEW' })).not.toBeInTheDocument(); + }); + + it('renders no pill for a stable build', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('stable'), status: 'ready', retry: vi.fn() }); + renderBar(); + expect(screen.queryByText('DEV')).not.toBeInTheDocument(); + expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument(); + }); + + it('keeps the tab touch targets present alongside the pill', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() }); + renderBar(); + expect(screen.getByRole('button', { name: 'Home' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Stacks' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + }); + + it('lays the pill in-flow as a non-overlapping sibling of the tabs', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() }); + renderBar(); + const pill = screen.getByText('DEV'); + // In-flow (flex sibling), not absolutely positioned over the Settings tab. + expect(pill).not.toHaveClass('absolute'); + expect(pill).toHaveClass('self-center', 'shrink-0'); + // Sibling of the tab buttons inside the nav, so flexbox reserves its own + // region rather than letting it overlap the rightmost tab. + const settingsTab = screen.getByRole('button', { name: 'Settings' }); + expect(pill.parentElement).toBe(settingsTab.parentElement); + }); +}); diff --git a/frontend/src/components/MobileTabBar.tsx b/frontend/src/components/MobileTabBar.tsx index 8edb096b..b9952a7a 100644 --- a/frontend/src/components/MobileTabBar.tsx +++ b/frontend/src/components/MobileTabBar.tsx @@ -1,6 +1,7 @@ import { Home, Layers, Radar, Clock, Settings as SettingsIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState'; import type { MobileView } from './EditorLayout/mobile-surface'; @@ -45,7 +46,12 @@ export function MobileTabBar({ onNavigate, onSettings, }: MobileTabBarProps) { + const { buildInfo } = useBuildInfo(); + const has = (value: ActiveView) => navItems.some(i => i.value === value); + const channel = buildInfo?.channel; + const showPill = channel === 'dev' || channel === 'preview'; + const pillLabel = channel === 'dev' ? 'DEV' : 'PREVIEW'; const tabs: Tab[] = [ { id: 'home', label: 'Home', icon: Home }, @@ -79,7 +85,7 @@ export function MobileTabBar({ aria-label="Primary mobile" data-sn-glass="mobile-tabbar" className={cn( - 'md:hidden flex shrink-0 items-stretch', + 'md:hidden relative flex shrink-0 items-stretch', 'border-t border-hairline', 'bg-[color-mix(in_oklch,var(--card)_70%,transparent)] backdrop-blur-md backdrop-saturate-150', 'pb-[max(8px,env(safe-area-inset-bottom))]', @@ -108,6 +114,17 @@ export function MobileTabBar({ ); })} + {showPill ? ( + + {pillLabel} + + ) : null} ); } diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 8101ee1e..dba9af92 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -2,6 +2,7 @@ import { Fragment, type ReactNode, useMemo } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Menu, MoreHorizontal, Plus } from 'lucide-react'; import { Button } from './ui/button'; +import { ScrollArea } from './ui/scroll-area'; import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { @@ -33,7 +34,7 @@ export interface TopBarNavItem { interface TopBarProps { activeView: string; - /** Flat page destinations for Classic strip and the mobile sheet. */ + /** Flat page destinations for the mobile sheet. */ navItems: TopBarNavItem[]; onNavigate: (value: string) => void; mobileNavOpen: boolean; @@ -74,6 +75,28 @@ function ActiveUnderline({ active }: { active: boolean }) { ); } +// Two bars that morph into an X, keyed off Radix's data-state attribute (already +// stamped on the trigger button by DropdownMenuTrigger asChild, so no extra React +// state is needed). A plain CSS transition, not a JS animation loop: Sencho's global +// [data-motion="reduced"] clamp (index.css) forces transition-duration to ~0ms for +// every element, so this automatically just swaps state under Reduced motion with +// zero extra code. The trigger button must carry the `group` class for the +// group-data-[state=open]: variants below to apply. +function LauncherHamburgerIcon() { + // Both bars share everything but the direction they start from and rotate to. + const bar = cn( + 'absolute h-[1.5px] w-3.5 rounded-full bg-current', + 'transition-transform duration-[var(--duration-fast)] ease-[var(--ease-out-expo)]', + 'group-data-[state=open]:translate-y-0', + ); + return ( + + + + + ); +} + function TopBarMenuMasthead({ title }: { title: string }) { return (
@@ -86,6 +109,24 @@ function TopBarMenuMasthead({ title }: { title: string }) { ); } +// One scroll owner. The popper content and the ScrollArea viewport are both +// capped at the available height, but the content clips (overflow-hidden, +// overriding the base DropdownMenuContent's overflow-y-auto) and only the +// viewport scrolls. +// +// The viewport's cap has to be a max-height on the viewport itself. The viewport +// is sized by `h-full` (see ui/scroll-area), and a percentage height only +// resolves against a containing block whose height is definite. The popper +// content is `height: auto` clamped by `max-height`, which is not definite, so +// neither is anything sized from it, a flex item included, so `h-full` falls +// back to auto and the viewport grows to its full content height with nothing +// to scroll. A max-height clamps the viewport whatever its height resolves to. +// (`` stays correct wherever the flex +// container's own height is definite, as in the sheets and sidebars.) +// +// The masthead scrolls with the list rather than being pinned outside the +// scroll region, so the cap needs no masthead-height arithmetic and stays +// correct at every density setting. function PanelMenuContent({ title, children, @@ -95,8 +136,10 @@ function PanelMenuContent({ }) { return ( - {title ? : null} -
{children}
+ + {title ? : null} +
{children}
+
); } @@ -204,32 +247,6 @@ function GroupedMenuItems({ ); } -function ClassicStrip({ - navItems, - activeView, - showLabels, - onNavigate, -}: { - navItems: TopBarNavItem[]; - activeView: string; - showLabels: boolean; - onNavigate: (value: string) => void; -}) { - return ( - <> - {navItems.map((item) => ( - - ))} - - ); -} - function SmartStrip({ primaryItems, overflowGroups, @@ -386,9 +403,9 @@ function CompactStrip({ aria-label="Open navigation launcher" aria-current={launcherActive ? 'page' : undefined} data-sn-launcher-active={launcherActive ? 'true' : 'false'} - className={navButtonClass(launcherActive)} + className={cn(navButtonClass(launcherActive), 'group')} > - + @@ -453,11 +470,7 @@ function CompactStrip({ ) : ( - +
diff --git a/frontend/src/components/settings/AboutSection.tsx b/frontend/src/components/settings/AboutSection.tsx index 83c8016e..6a1d6bd0 100644 --- a/frontend/src/components/settings/AboutSection.tsx +++ b/frontend/src/components/settings/AboutSection.tsx @@ -1,5 +1,11 @@ +import { useState } from 'react'; import { useLicense } from '@/context/LicenseContext'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; import { TierBadge } from '@/components/TierBadge'; +import { Badge } from '@/components/ui/badge'; +import { FlaskConical } from 'lucide-react'; +import { copyToClipboard } from '@/lib/clipboard'; +import { toast } from '@/components/ui/toast-store'; import { TogglePill } from '@/components/ui/toggle-pill'; import { useWhatsNewPreference } from '@/hooks/useWhatsNewPreference'; import { whatsNewEntries } from '@/whats-new/entries'; @@ -15,16 +21,110 @@ import { const linkClassName = 'font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-brand hover:text-brand/80 transition-colors'; +const mono = 'font-mono text-sm text-stat-value'; + +function BuildChannelChip({ label }: { label: string }) { + if (label === 'Dev') { + return ( + + Dev + + ); + } + if (label === 'Preview') { + return ( + + Preview + + ); + } + return {label}; +} + export function AboutSection() { const { license } = useLicense(); + const { buildInfo, status } = useBuildInfo(); const { enabled: whatsNewEnabled, setEnabled: setWhatsNewEnabled } = useWhatsNewPreference(); + const [copied, setCopied] = useState(false); + + // Loading surfaces a placeholder and error surfaces "Unknown" (truthful); + // the reference and revision fields below read "Restricted" for a redacted + // hardened image, before their null-check. + const channelLabel = (() => { + if (status === 'loading') return '…'; + if (status === 'error' || !buildInfo) return 'Unknown'; + switch (buildInfo.channel) { + case 'dev': return 'Dev'; + case 'preview': return 'Preview'; + case 'stable': return 'Stable'; + default: return 'Unknown'; + } + })(); + const resolveLabel = (value: string | null | undefined) => + buildInfo?.restricted + ? 'Restricted' + : status === 'loading' + ? '…' + : status === 'error' + ? 'Unknown' + : value ?? 'Unknown'; + + const imageRefLabel = resolveLabel(buildInfo?.imageRef); + const revisionLabel = resolveLabel(buildInfo?.revision); + const imageIdLabel = + status === 'loading' ? '…' + : status === 'error' || !buildInfo?.imageId ? 'Unknown' + : `sha256:${buildInfo.imageId.slice(0, 12)}`; + + const copyImageId = async () => { + if (!buildInfo?.imageId) return; + try { + await copyToClipboard(`sha256:${buildInfo.imageId}`); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + toast.error('Could not copy the image id.'); + } + }; return (
- v{__APP_VERSION__} + v{buildInfo?.version ?? __APP_VERSION__} + + + + + {imageRefLabel} + + + {revisionLabel} + + {imageIdLabel !== 'Unknown' && imageIdLabel !== '…' ? ( + + + + ) : null} diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx index db3f33cc..fcb9a455 100644 --- a/frontend/src/components/settings/AppearanceSection.tsx +++ b/frontend/src/components/settings/AppearanceSection.tsx @@ -43,9 +43,8 @@ const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [ ]; const TOP_NAV_MODE_OPTIONS: { value: TopNavMode; label: string }[] = [ - { value: 'classic', label: 'Classic bar' }, - { value: 'smart', label: 'Smart bar' }, { value: 'compact', label: 'Compact launcher' }, + { value: 'smart', label: 'Smart bar' }, ]; const CHART_STYLE_OPTIONS: { value: ChartStyle; label: string }[] = [ @@ -147,8 +146,10 @@ function VisualCard({ export function AppearanceSection({ quickLinkCandidates = [], + defaultQuickLinkEligibility, }: { quickLinkCandidates?: NavDestination[]; + defaultQuickLinkEligibility?: ActiveView[] | null; }) { const [density, setDensity] = useDensity(); const [chipColorMode, setChipColorMode] = useLogChipColorMode(); @@ -157,10 +158,11 @@ export function AppearanceSection({ const [topNavMode, setTopNavMode] = useTopNavMode(); const { persistedIds: quickLinkIds, + canReset, addQuickLink, removeQuickLink, resetQuickLinks, - } = useTopNavQuickLinks(); + } = useTopNavQuickLinks(defaultQuickLinkEligibility); const persistedSet = new Set(quickLinkIds); const unpinnedCandidates = quickLinkCandidates.filter((item) => !persistedSet.has(item.value)); const atCapacity = quickLinkIds.length >= MAX_QUICK_LINKS; @@ -459,17 +461,9 @@ export function AppearanceSection({ - {topNavMode === 'classic' ? ( - } - title="Classic bar retiring" - subtitle="Classic bar will be removed soon. Your preference is kept until then." - /> - ) : null} - {(topNavMode === 'classic' || topNavMode === 'smart') && ( + {topNavMode === 'smart' && ( )} - {(topNavMode === 'classic' || topNavMode === 'smart') && !topNavLabels && ( + {topNavMode === 'smart' && !topNavLabels && (
{quickLinkIds.length === 0 ? ( @@ -553,7 +547,7 @@ export function AppearanceSection({ )}
- + Reset to defaults diff --git a/frontend/src/components/settings/LicenseSection.tsx b/frontend/src/components/settings/LicenseSection.tsx index d1981dc8..7a2eb023 100644 --- a/frontend/src/components/settings/LicenseSection.tsx +++ b/frontend/src/components/settings/LicenseSection.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui/toast-store'; import { useLicense } from '@/context/LicenseContext'; +import { useBuildInfo } from '@/hooks/useBuildInfo'; import { TierBadge } from '@/components/TierBadge'; import { Crown, CheckCircle, XCircle, Clock, ExternalLink, @@ -47,8 +48,8 @@ function formatChannel(channel: ImageChannel): string { return 'Community'; case 'hardened': return 'Hardened'; - default: - return 'Custom'; + case 'unknown': + return 'Unknown'; } } @@ -64,6 +65,7 @@ function getTierMastheadValue(tier?: string): string { export function LicenseSection() { const { license, isPaid, activate, deactivate } = useLicense(); + const { buildInfo, status: buildInfoStatus } = useBuildInfo(); const [licenseKeyInput, setLicenseKeyInput] = useState(''); const [isActivating, setIsActivating] = useState(false); const [isDeactivating, setIsDeactivating] = useState(false); @@ -253,16 +255,28 @@ export function LicenseSection() { ) : null} - {channelStatus?.composeImageRef ?? formatChannel(channelStatus?.channel ?? 'unknown')} + {buildInfo?.restricted + ? 'Restricted' + : buildInfoStatus === 'loading' + ? '…' + : buildInfoStatus === 'error' + ? 'Unknown' + : buildInfo?.imageRef ?? 'Unknown'} - {formatChannel(channelStatus?.channel ?? 'unknown')} + + {buildInfoStatus === 'loading' + ? '…' + : buildInfoStatus === 'error' || !buildInfo + ? 'Unknown' + : formatChannel(buildInfo.imageChannel)} + {channelStatus?.operation?.state === 'failed' ? ( void; onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; quickLinkCandidates?: NavDestination[]; + defaultQuickLinkEligibility?: ActiveView[] | null; } export function SettingsPage(props: SettingsPageProps) { @@ -54,6 +56,7 @@ function SettingsPageInner({ onMutePrefillConsumed, onOpenMuteRulesWithPrefill, quickLinkCandidates, + defaultQuickLinkEligibility, }: SettingsPageProps) { const { activeNode } = useNodes(); const visibility = useSettingsVisibility(); @@ -212,6 +215,7 @@ function SettingsPageInner({ onMutePrefillConsumed={onMutePrefillConsumed} onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} quickLinkCandidates={quickLinkCandidates} + defaultQuickLinkEligibility={defaultQuickLinkEligibility} />
diff --git a/frontend/src/components/settings/SettingsSectionContent.tsx b/frontend/src/components/settings/SettingsSectionContent.tsx index 5f8f22a3..6d472d9a 100644 --- a/frontend/src/components/settings/SettingsSectionContent.tsx +++ b/frontend/src/components/settings/SettingsSectionContent.tsx @@ -26,6 +26,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules'; import LazyBoundary from '../LazyBoundary'; import { SectionGate } from './SectionGate'; import type { NavDestination } from '@/lib/navigation/appNavRegistry'; +import type { ActiveView } from '@/lib/router/routeTypes'; // Paid-tier sections are loaded on demand. SectionGate returns null for // Community / unentitled operators before reaching the JSX that would mount @@ -74,17 +75,23 @@ function SectionSkeleton() { ); } -function renderSection( - sectionId: SectionId, - onDirtyChange: (section: SectionId, dirty: boolean) => void, - muteRulePrefill: MuteRuleDraft | null | undefined, - onMutePrefillConsumed: (() => void) | undefined, - onOpenMuteRulesWithPrefill: ((draft: MuteRuleDraft) => void) | undefined, - quickLinkCandidates: NavDestination[] | undefined, -) { +function renderSection({ + sectionId, + onDirtyChange, + muteRulePrefill, + onMutePrefillConsumed, + onOpenMuteRulesWithPrefill, + quickLinkCandidates, + defaultQuickLinkEligibility, +}: Omit) { switch (sectionId) { case 'account': return ; - case 'appearance': return ; + case 'appearance': return ( + + ); case 'license': return ; case 'users': return ; case 'sso': return ; @@ -128,6 +135,7 @@ interface SettingsSectionContentProps { onMutePrefillConsumed?: () => void; onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; quickLinkCandidates?: NavDestination[]; + defaultQuickLinkEligibility?: ActiveView[] | null; } /** @@ -144,18 +152,20 @@ export function SettingsSectionContent({ onMutePrefillConsumed, onOpenMuteRulesWithPrefill, quickLinkCandidates, + defaultQuickLinkEligibility, }: SettingsSectionContentProps) { const item = getSettingsItem(sectionId); const element = useMemo( - () => renderSection( + () => renderSection({ sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill, quickLinkCandidates, - ), - [sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill, quickLinkCandidates], + defaultQuickLinkEligibility, + }), + [sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill, quickLinkCandidates, defaultQuickLinkEligibility], ); return ( <> diff --git a/frontend/src/components/settings/__tests__/AboutSection.test.tsx b/frontend/src/components/settings/__tests__/AboutSection.test.tsx index 5296b5ba..d62375c3 100644 --- a/frontend/src/components/settings/__tests__/AboutSection.test.tsx +++ b/frontend/src/components/settings/__tests__/AboutSection.test.tsx @@ -42,6 +42,36 @@ vi.mock('@/hooks/useWhatsNewPreference', () => ({ useWhatsNewPreference: () => ({ enabled: true, setEnabled: mockSetEnabled, hasUnseen: false, markSeen: vi.fn() }), })); +vi.mock('@/hooks/useBuildInfo', () => ({ + useBuildInfo: vi.fn(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() })), +})); +import { useBuildInfo } from '@/hooks/useBuildInfo'; +import type { BuildInfo } from '@/context/BuildInfoProvider'; + +const { mockCopyToClipboard, mockToastError } = vi.hoisted(() => ({ + mockCopyToClipboard: vi.fn(), + mockToastError: vi.fn(), +})); +vi.mock('@/lib/clipboard', () => ({ copyToClipboard: mockCopyToClipboard })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: mockToastError, success: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, +})); + +const mockUseBuildInfo = vi.mocked(useBuildInfo); + +function buildInfo(over: Partial = {}): BuildInfo { + return { + version: '0.97.1', + channel: 'dev', + imageChannel: 'community', + imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev', + imageId: 'a'.repeat(64), + revision: 'dev-abc1234', + restricted: false, + ...over, + }; +} + // The shipped entries.json is empty, so populate it here; the empty state has its own file. vi.mock('@/whats-new/entries', () => ({ whatsNewEntries: [{ id: 'entry-a', title: 'A feature', blurb: 'Does a thing.' }], @@ -91,3 +121,49 @@ describe('AboutSection Preferences', () => { expect(mockSetEnabled).toHaveBeenCalledWith(false); }); }); + +describe('AboutSection Build identity', () => { + it('shows the runtime channel, current image, revision and version', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() }); + render(); + expect(screen.getByText('Dev')).toBeTruthy(); + expect(screen.getByText('ghcr.io/studio-saelix/sencho-dev:dev')).toBeTruthy(); + expect(screen.getByText('dev-abc1234')).toBeTruthy(); + expect(screen.getByText('v0.97.1')).toBeTruthy(); + }); + + it('labels redacted hardened reference fields Restricted, not Unknown', () => { + mockUseBuildInfo.mockReturnValue({ + buildInfo: buildInfo({ channel: 'stable', restricted: true, imageRef: null, revision: null }), + status: 'ready', + retry: vi.fn(), + }); + render(); + expect(screen.getByText('Stable')).toBeTruthy(); + expect(screen.getAllByText('Restricted').length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText('Unknown')).toBeNull(); + }); + + it('shows Unknown for reference fields when build info is unavailable', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: null, status: 'error', retry: vi.fn() }); + render(); + expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0); + }); + + it('wraps long image and revision tokens so they do not overflow', () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() }); + render(); + expect(screen.getByText('ghcr.io/studio-saelix/sencho-dev:dev')).toHaveClass('break-all'); + expect(screen.getByText('dev-abc1234')).toHaveClass('break-all'); + }); + + it('surfaces an error toast when copying the image id fails', async () => { + mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() }); + mockCopyToClipboard.mockRejectedValue(new Error('clipboard blocked')); + render(); + + await userEvent.click(screen.getByRole('button', { name: /sha256:/ })); + expect(mockCopyToClipboard).toHaveBeenCalledWith(`sha256:${'a'.repeat(64)}`); + expect(mockToastError).toHaveBeenCalledWith('Could not copy the image id.'); + }); +}); diff --git a/frontend/src/components/settings/__tests__/AboutSection.whatsNewEmpty.test.tsx b/frontend/src/components/settings/__tests__/AboutSection.whatsNewEmpty.test.tsx index 181a3c7d..8c0b92fa 100644 --- a/frontend/src/components/settings/__tests__/AboutSection.whatsNewEmpty.test.tsx +++ b/frontend/src/components/settings/__tests__/AboutSection.whatsNewEmpty.test.tsx @@ -43,6 +43,10 @@ vi.mock('@/hooks/useWhatsNewPreference', () => ({ useWhatsNewPreference: () => ({ enabled: true, setEnabled: vi.fn(), hasUnseen: false, markSeen: vi.fn() }), })); +vi.mock('@/hooks/useBuildInfo', () => ({ + useBuildInfo: () => ({ buildInfo: null, status: 'ready', retry: vi.fn() }), +})); + describe("AboutSection with no What's New entries authored", () => { it('hides the Preferences section entirely, so no toggle describes an absent icon', () => { render(); diff --git a/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx b/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx index 01fe1237..e542ce71 100644 --- a/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx +++ b/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx @@ -173,32 +173,39 @@ describe('AppearanceSection', () => { localStorage.clear(); render(); expect(screen.getByText('Navigation')).toBeTruthy(); - expect(screen.getByRole('radiogroup', { name: 'Navigation style' })).toBeTruthy(); - // Smart default shows label toggle, hides quick links. + const navigationStyle = screen.getByRole('radiogroup', { name: 'Navigation style' }); + expect(navigationStyle).toBeTruthy(); + // Compact is the default: shows quick links, hides label/alignment controls. + expect(screen.getByText('Quick links')).toBeTruthy(); + expect(screen.queryByText('Top navigation labels')).toBeNull(); + + fireEvent.click(screen.getByRole('radio', { name: 'Smart bar' })); expect(screen.getByText('Top navigation labels')).toBeTruthy(); expect(screen.queryByText('Quick links')).toBeNull(); fireEvent.click(screen.getByRole('radio', { name: 'Compact launcher' })); expect(screen.getByText('Quick links')).toBeTruthy(); expect(screen.queryByText('Top navigation labels')).toBeNull(); - - fireEvent.click(screen.getByRole('radio', { name: 'Classic bar' })); - expect(screen.getByText('Top navigation labels')).toBeTruthy(); - expect(screen.queryByText('Quick links')).toBeNull(); }); - it('shows the Classic bar retiring callout only while Classic is selected', () => { + it('offers only Compact launcher and Smart bar, with Compact first', () => { localStorage.clear(); render(); - expect(screen.queryByText('Classic bar retiring')).toBeNull(); - - fireEvent.click(screen.getByRole('radio', { name: 'Classic bar' })); - expect(screen.getByText('Classic bar retiring')).toBeTruthy(); - expect( - screen.getByText('Classic bar will be removed soon. Your preference is kept until then.'), - ).toBeTruthy(); - - fireEvent.click(screen.getByRole('radio', { name: 'Compact launcher' })); + const options = screen.getAllByRole('radio', { name: /bar|launcher/i }).map((el) => el.textContent); + expect(options).toEqual(['Compact launcher', 'Smart bar']); + expect(screen.queryByRole('radio', { name: 'Classic bar' })).toBeNull(); expect(screen.queryByText('Classic bar retiring')).toBeNull(); }); + + it('disables Reset to defaults while default eligibility has not settled', () => { + localStorage.clear(); + render(); + expect((screen.getByRole('button', { name: 'Reset to defaults' }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('enables Reset to defaults once default eligibility has settled', () => { + localStorage.clear(); + render(); + expect((screen.getByRole('button', { name: 'Reset to defaults' }) as HTMLButtonElement).disabled).toBe(false); + }); }); diff --git a/frontend/src/components/settings/__tests__/LicenseSection.test.tsx b/frontend/src/components/settings/__tests__/LicenseSection.test.tsx index 257bc0fe..d8d99605 100644 --- a/frontend/src/components/settings/__tests__/LicenseSection.test.tsx +++ b/frontend/src/components/settings/__tests__/LicenseSection.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import type { LicenseInfo } from '@/context/LicenseContext'; +import type { BuildInfoContextType } from '@/context/BuildInfoProvider'; const useLicenseMock = vi.fn(); @@ -12,6 +13,12 @@ vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {}, })); +const useBuildInfoMock = vi.fn<() => BuildInfoContextType>(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() })); + +vi.mock('@/hooks/useBuildInfo', () => ({ + useBuildInfo: () => useBuildInfoMock(), +})); + vi.mock('@/components/TierBadge', () => ({ TierBadge: () => tier, })); @@ -135,3 +142,72 @@ describe('LicenseSection pricing link', () => { expect(screen.getByText('See pricing')).toBeTruthy(); }); }); + +describe('LicenseSection build rows (running identity consistency)', () => { + beforeEach(() => { + useLicenseMock.mockReset(); + useBuildInfoMock.mockReset(); + useBuildInfoMock.mockReturnValue({ buildInfo: null, status: 'ready', retry: vi.fn() }); + mockLicense(baseLicense()); + }); + + it('renders the running Community channel and image ref, regardless of configured target', () => { + useBuildInfoMock.mockReturnValue({ + buildInfo: { + version: '0.97.1', + channel: 'stable', + imageChannel: 'community', + imageRef: 'ghcr.io/studio-saelix/sencho:0.97.1', + imageId: 'a'.repeat(64), + revision: null, + restricted: false, + }, + status: 'ready', + retry: vi.fn(), + }); + render(); + // The configured/compose target is hardened, but the running build is + // Community: the row must show the running image, never the target. + expect(screen.getByText('ghcr.io/studio-saelix/sencho:0.97.1')).toBeTruthy(); + expect(screen.queryByText('Hardened')).toBeNull(); + }); + + it('renders a hardened running image as Hardened channel and Restricted image, not Unknown', () => { + useBuildInfoMock.mockReturnValue({ + buildInfo: { + version: '0.97.1', + channel: 'stable', + imageChannel: 'hardened', + imageRef: null, + imageId: 'b'.repeat(64), + revision: null, + restricted: true, + }, + status: 'ready', + retry: vi.fn(), + }); + render(); + expect(screen.getByText('Hardened')).toBeTruthy(); + expect(screen.getByText('Restricted')).toBeTruthy(); + expect(screen.queryByText('Unknown')).toBeNull(); + }); + + it('labels an unclassifiable running image Channel Unknown, never Custom', () => { + useBuildInfoMock.mockReturnValue({ + buildInfo: { + version: '0.97.1', + channel: 'unknown', + imageChannel: 'unknown', + imageRef: null, + imageId: null, + revision: null, + restricted: false, + }, + status: 'ready', + retry: vi.fn(), + }); + render(); + expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0); + expect(screen.queryByText('Custom')).toBeNull(); + }); +}); diff --git a/frontend/src/components/sidebar/SidebarBrand.test.tsx b/frontend/src/components/sidebar/SidebarBrand.test.tsx new file mode 100644 index 00000000..19b6f59a --- /dev/null +++ b/frontend/src/components/sidebar/SidebarBrand.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SidebarBrand } from './SidebarBrand'; +import { chipDetail } from './chipDetail'; +import type { BuildInfo } from '@/context/BuildInfoProvider'; + +function info(channel: BuildInfo['channel']): BuildInfo { + return { + version: '0.97.1', + channel, + imageChannel: 'community', + imageRef: channel === 'dev' ? 'ghcr.io/studio-saelix/sencho-dev:dev' : 'ghcr.io/studio-saelix/sencho:0.97.1', + imageId: 'a'.repeat(64), + revision: null, + restricted: false, + }; +} + +describe('SidebarBrand build-identity chip', () => { + it('shows a DEV text chip for a dev build', () => { + render(); + const chip = screen.getByText('DEV'); + expect(chip).toBeInTheDocument(); + // Text is the cue, not color alone. + expect(chip.tagName).toBe('SPAN'); + expect(chip.textContent).toContain('DEV'); + }); + + it('shows a PREVIEW text chip for a preview build', () => { + render(); + expect(screen.getByText('PREVIEW')).toBeInTheDocument(); + }); + + it('renders no chip for a stable build', () => { + render(); + expect(screen.queryByText('DEV')).not.toBeInTheDocument(); + expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument(); + }); + + it('renders no chip when build info is unavailable', () => { + render(); + expect(screen.queryByText('DEV')).not.toBeInTheDocument(); + expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument(); + }); + + it('prefers the runtime version when available', () => { + render(); + expect(screen.getByText('v0.97.1')).toBeInTheDocument(); + }); +}); + +describe('chipDetail', () => { + it('reads Restricted for a redacted hardened reference', () => { + const b: BuildInfo = { ...info('stable'), restricted: true, imageRef: null, revision: null }; + expect(chipDetail(b)).toBe('Restricted'); + }); + + it('combines the reference and revision when both are present', () => { + const b: BuildInfo = { ...info('dev'), revision: 'dev-abc1234' }; + expect(chipDetail(b)).toBe('ghcr.io/studio-saelix/sencho-dev:dev · dev-abc1234'); + }); + + it('reads the reference alone when the revision is unknown', () => { + const b: BuildInfo = { ...info('dev'), revision: null }; + expect(chipDetail(b)).toBe('ghcr.io/studio-saelix/sencho-dev:dev'); + }); + + it('reads Unknown when the reference is absent and not restricted', () => { + const b: BuildInfo = { ...info('dev'), imageRef: null }; + expect(chipDetail(b)).toBe('Unknown'); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/sidebar/SidebarBrand.tsx b/frontend/src/components/sidebar/SidebarBrand.tsx index 8e5f0a78..08d867e5 100644 --- a/frontend/src/components/sidebar/SidebarBrand.tsx +++ b/frontend/src/components/sidebar/SidebarBrand.tsx @@ -1,8 +1,17 @@ +import { FlaskConical } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import type { BuildInfo } from '@/context/BuildInfoProvider'; +import { chipDetail } from './chipDetail'; + interface SidebarBrandProps { isDarkMode: boolean; + buildInfo?: BuildInfo | null; } -export function SidebarBrand({ isDarkMode }: SidebarBrandProps) { +export function SidebarBrand({ isDarkMode, buildInfo }: SidebarBrandProps) { + const channel = buildInfo?.channel; + const showChip = channel === 'dev' || channel === 'preview'; + return (
-
+
Sencho - v{__APP_VERSION__} + v{buildInfo?.version ?? __APP_VERSION__} + {showChip ? ( + + + + + {channel === 'dev' ? : null} + {channel === 'dev' ? 'DEV' : 'PREVIEW'} + + + + {chipDetail(buildInfo)} + + + + ) : null}
); diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx index 1f1ecb25..4d78caf0 100644 --- a/frontend/src/components/sidebar/StackSidebar.tsx +++ b/frontend/src/components/sidebar/StackSidebar.tsx @@ -11,10 +11,12 @@ import { StackList, type StackListProps } from './StackList'; import type { FilterChip } from './sidebar-types'; import type { BulkAction } from '@/hooks/useBulkStackActions'; import type { SidebarActivitySummary } from './useSidebarActivitySummary'; +import type { BuildInfo } from '@/context/BuildInfoProvider'; import { isStacksListSettled } from './stacksLoadUi'; export interface StackSidebarProps { isDarkMode: boolean; + buildInfo?: BuildInfo | null; nodeSwitcherSlot: ReactNode; createStackSlot: ReactNode | null; onScan: () => void; @@ -43,7 +45,7 @@ export interface StackSidebarProps { export function StackSidebar(props: StackSidebarProps) { const { - isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate, + isDarkMode, buildInfo, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate, searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange, list, activitySummary, onActivityAction, bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction, @@ -76,7 +78,7 @@ export function StackSidebar(props: StackSidebarProps) { its kicker chip), so the in-sidebar brand and node rows are redundant there and hidden to save vertical space. */}
- +
{nodeSwitcherSlot}
{canCreate && createStackSlot !== null && ( diff --git a/frontend/src/components/sidebar/chipDetail.ts b/frontend/src/components/sidebar/chipDetail.ts new file mode 100644 index 00000000..e98ed8ae --- /dev/null +++ b/frontend/src/components/sidebar/chipDetail.ts @@ -0,0 +1,11 @@ +import type { BuildInfo } from '@/context/BuildInfoProvider'; + +/** Detail shown under the DEV/PREVIEW chip, or the truthful Unknown / Restricted + * states when the running reference is unavailable or redacted for this user. */ +export function chipDetail(buildInfo: BuildInfo | null | undefined): string { + if (buildInfo?.restricted) return 'Restricted'; + if (buildInfo?.imageRef) { + return buildInfo.revision ? `${buildInfo.imageRef} · ${buildInfo.revision}` : buildInfo.imageRef; + } + return 'Unknown'; +} \ No newline at end of file diff --git a/frontend/src/components/stack/GitSourceFields.tsx b/frontend/src/components/stack/GitSourceFields.tsx index 91c20a9c..d5439f09 100644 --- a/frontend/src/components/stack/GitSourceFields.tsx +++ b/frontend/src/components/stack/GitSourceFields.tsx @@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; @@ -39,11 +40,15 @@ export interface GitSourceFieldsState { authType: 'none' | 'token' | 'deploy_key'; token: string; deployKey: string; + caBundle: string; sshKnownHostsEntry: string; sshHostKeyFingerprint: string; /** When editing an existing source, the server tells us whether a token is already stored. */ hasStoredToken: boolean; hasStoredDeployKey: boolean; + hasStoredCaBundle: boolean; + /** Explicit revocation is armed: the next save sends `remove_ca_bundle: true`. */ + removeCaBundle: boolean; storedHostKeyFingerprint: string | null; applyMode: ApplyMode; } @@ -62,6 +67,9 @@ export interface GitSourceFieldsProps extends GitSourceFieldsState { onAuthTypeChange: (value: 'none' | 'token' | 'deploy_key') => void; onTokenChange: (value: string) => void; onDeployKeyChange: (value: string) => void; + onCaBundleChange: (value: string) => void; + /** Explicit revocation: the operator clicked "Remove stored CA". Sends `remove_ca_bundle: true` on the next save. */ + onRemoveCaBundle: () => void; onSshKnownHostsEntryChange: (value: string) => void; onSshHostKeyFingerprintChange: (value: string) => void; onApplyModeChange: (value: ApplyMode) => void; @@ -91,9 +99,12 @@ export function GitSourceFields({ authType, token, deployKey, + caBundle, sshHostKeyFingerprint, hasStoredToken, hasStoredDeployKey, + hasStoredCaBundle, + removeCaBundle, storedHostKeyFingerprint, applyMode, disabled = false, @@ -106,6 +117,8 @@ export function GitSourceFields({ onAuthTypeChange, onTokenChange, onDeployKeyChange, + onCaBundleChange, + onRemoveCaBundle, onSshKnownHostsEntryChange, onSshHostKeyFingerprintChange, onApplyModeChange, @@ -115,6 +128,7 @@ export function GitSourceFields({ const copy = APPLY_MODE_COPY[variant]; const primaryComposePath = composePaths[0] ?? ''; const canBrowse = !!repoUrl?.trim() && !!branch?.trim(); + const isHttpsRepo = /^https:\/\//i.test(repoUrl.trim()); const [hostKeyRotation, setHostKeyRotation] = useState(null); useEffect(() => { @@ -362,6 +376,46 @@ export function GitSourceFields({ )}
+ {isHttpsRepo && ( +
+ +