diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000000..7d66345b33f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,157 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '17 3 * * 0' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + actions: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: manual + - language: java-kotlin + build-mode: manual + - language: python + build-mode: none + + steps: + - name: Check out analysis source + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - name: Record source identity + shell: bash + env: + EVENT_SHA: ${{ github.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_REF: ${{ github.ref }} + run: | + set -Eeuo pipefail + printf 'event_sha=%s\npr_head_sha=%s\nevent_ref=%s\n' \ + "$EVENT_SHA" "$PR_HEAD_SHA" "$EVENT_REF" + printf 'checked_out_sha=%s\nchecked_out_tree=%s\n' \ + "$(git rev-parse HEAD)" "$(git rev-parse HEAD^{tree})" + test "$(git rev-parse HEAD)" = "$EVENT_SHA" + + - name: Set up the production Go compiler + if: matrix.language == 'go' + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + # server/Dockerfile.web-compose-release ARTIFACT_HELPER_IMAGE. + go-version: '1.27.2' + check-latest: false + cache: false + + - name: Set up the production Java compiler + if: matrix.language == 'java-kotlin' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + # server/Dockerfile TEMURIN_JDK25_VERSION. + distribution: temurin + java-version: '25.0.4+7.0.LTS' + check-latest: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config: | + name: PastureStack complete extended analysis + queries: + - uses: security-extended + threat-models: local + + - name: Build the Go console broker for analysis + if: matrix.language == 'go' + working-directory: server/console-broker + shell: bash + env: + CGO_ENABLED: '0' + GOOS: linux + GOARCH: amd64 + GOTOOLCHAIN: local + run: | + set -Eeuo pipefail + test "$(go env GOVERSION)" = 'go1.27.2' + mkdir -p "$RUNNER_TEMP/codeql-go-build" + go build \ + -mod=readonly \ + -trimpath \ + -buildvcs=false \ + -ldflags='-s -w -buildid=' \ + -o "$RUNNER_TEMP/codeql-go-build/pasturestack-console-broker" . + + - name: Compile Java sources with the production Engine libraries + if: matrix.language == 'java-kotlin' + shell: bash + run: | + set -Eeuo pipefail + dockerfile=server/Dockerfile.web-compose-release + read_arg() { + local arg_name="$1" + local -a values + mapfile -t values < <(sed -n "s/^ARG ${arg_name}=//p" "$dockerfile" | sort -u) + test "${#values[@]}" -eq 1 || return 1 + test -n "${values[0]}" || return 1 + printf '%s' "${values[0]}" + } + release_base="$(read_arg ORCHESTRATION_ENGINE_RELEASE_BASE_URL)" + release_tag="$(read_arg ORCHESTRATION_ENGINE_RELEASE_TAG)" + artifact="$(read_arg ORCHESTRATION_ENGINE_ARTIFACT)" + expected_sha256="$(read_arg ORCHESTRATION_ENGINE_ARTIFACT_SHA256)" + test "$release_base" = 'https://github.com/PastureStack/orchestration-engine/releases/download' + [[ "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "$artifact" = cattle.jar + [[ "$expected_sha256" =~ ^[0-9a-f]{64}$ ]] + + build_root="$RUNNER_TEMP/codeql-java-build" + mkdir -p "$build_root/lib" "$build_root/classes" + archive="$build_root/cattle.jar" + curl --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' \ + --retry 3 --connect-timeout 15 --max-time 600 \ + --output "$archive" "$release_base/$release_tag/$artifact" + printf '%s %s\n' "$expected_sha256" "$archive" | sha256sum --check --strict + unzip -j -q "$archive" 'WEB-INF/lib/*.jar' -d "$build_root/lib" + compgen -G "$build_root/lib/*.jar" > /dev/null + + # Compile every tracked Java source; the reviewed tree contains one + # PatchV1GlobalSubscribe.java and no Kotlin sources or Maven project. + # Failing on future Kotlin additions avoids silently omitting them. + mapfile -d '' -t kotlin_sources < <(git ls-files -z '*.kt' '*.kts') + test "${#kotlin_sources[@]}" -eq 0 + mapfile -d '' -t java_sources < <(git ls-files -z '*.java') + test "${#java_sources[@]}" -gt 0 + javac -cp "$build_root/lib/*" -d "$build_root/classes" "${java_sources[@]}" + + - name: Analyze and upload CodeQL results + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + category: '/language:${{ matrix.language }}' + wait-for-processing: true diff --git a/.github/workflows/publish-current-server.yml b/.github/workflows/publish-current-server.yml index 9d7f28d937d..700314f814b 100644 --- a/.github/workflows/publish-current-server.yml +++ b/.github/workflows/publish-current-server.yml @@ -68,9 +68,13 @@ jobs: test "$(git -C source rev-parse HEAD)" = "$SOURCE_SHA" test -z "$(git -C source status --short)" - declared_release="$(sed -n 's/.*org.opencontainers.image.version="\([^"]*\)".*/\1/p' \ - source/server/Dockerfile.web-compose-release)" + declared_release="$(sed -n 's/^ARG SERVER_RELEASE_TAG=//p' \ + source/server/Dockerfile.web-compose-release | sort -u)" test "$declared_release" = "$RELEASE_TAG" + host_api_version="$(sed -n 's/^ARG HOST_API_VERSION=//p' \ + source/server/Dockerfile.web-compose-release | sort -u)" + [[ "$host_api_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + printf 'HOST_API_VERSION=%s\n' "$host_api_version" >>"$GITHUB_ENV" release_notes="source/docs/releases/server-${RELEASE_TAG#v}.md" test -f "$release_notes" test -s "$release_notes" @@ -340,15 +344,32 @@ jobs: docker export --output "$RUNNER_TEMP/server-rootfs.tar" "$CANDIDATE_NAME" tar --no-same-owner -xf "$RUNNER_TEMP/server-rootfs.tar" -C "$rootfs" test "$(readlink "$rootfs/usr/share/cattle/artifacts/host-api.tar.gz")" = \ - host-api-0.38.4.tar.gz + "host-api-${HOST_API_VERSION}.tar.gz" grep -Fx \ - 'export CATTLE_AGENT_PACKAGE_HOST_API_URL=/usr/share/cattle/artifacts/host-api-0.38.4.tar.gz' \ + "export CATTLE_AGENT_PACKAGE_HOST_API_URL=/usr/share/cattle/artifacts/host-api-${HOST_API_VERSION}.tar.gz" \ "$rootfs/usr/share/cattle/env_vars" >/dev/null - cp "$rootfs/usr/share/cattle/artifacts/host-api-0.38.4.tar.gz" \ - "$evidence/host-api-0.38.4.tar.gz" - bash source/scripts/check-server-host-api-package.sh \ - "$evidence/host-api-0.38.4.tar.gz" | - tee "$evidence/host-api-package-check.txt" + cp "$rootfs/usr/share/cattle/artifacts/host-api-${HOST_API_VERSION}.tar.gz" \ + "$evidence/host-api-${HOST_API_VERSION}.tar.gz" + host_default() { + sed -n "s/^ARG ${1}=//p" source/server/Dockerfile.web-compose-release | sort -u + } + if [[ "$(host_default HOST_API_PACKAGE_MODE)" == producer ]]; then + printf '%s %s\n' "$(host_default HOST_API_ARCHIVE_SHA256)" \ + "$evidence/host-api-${HOST_API_VERSION}.tar.gz" | sha256sum -c - + bash source/server/artifacts/verify-host-api-package.sh \ + "$evidence/host-api-${HOST_API_VERSION}.tar.gz" \ + "$(host_default HOST_API_PACKAGE_ID)" \ + "$(host_default HOST_API_BINARY_SHA256)" \ + "$(host_default HOST_API_APPLY_SHA256)" \ + "$(host_default HOST_API_COMMIT)" | + tee "$evidence/host-api-package-check.txt" + else + test "$(host_default HOST_API_PACKAGE_MODE)" = legacy-repair + test "$HOST_API_VERSION" = 0.38.4 + bash source/scripts/check-server-host-api-package.sh \ + "$evidence/host-api-${HOST_API_VERSION}.tar.gz" | + tee "$evidence/host-api-package-check.txt" + fi printf 'SERVER_CANDIDATE_SMOKE_OK release=%s first_attempts=%s restart_attempts=%s catalog_attempts=%s first_contract_attempts=%s restart_contract_attempts=%s http=200 body=pong public_origin=https://stack.example.test private_api_cache=no-store\n' \ "$RELEASE_TAG" "$first_attempts" "$restart_attempts" "$catalog_attempts" \ "$first_contract_attempts" "$restart_contract_attempts" | @@ -560,7 +581,7 @@ jobs: build.log \ candidate.log \ candidate-smoke.txt \ - host-api-0.38.4.tar.gz \ + "host-api-${HOST_API_VERSION}.tar.gz" \ host-api-package-check.txt \ image-inspect.txt \ mfa-policy-api-smoke.txt \ @@ -590,7 +611,7 @@ jobs: "$evidence/build.log" \ "$evidence/candidate.log" \ "$evidence/candidate-smoke.txt" \ - "$evidence/host-api-0.38.4.tar.gz" \ + "$evidence/host-api-${HOST_API_VERSION}.tar.gz" \ "$evidence/host-api-package-check.txt" \ "$evidence/image-inspect.txt" \ "$evidence/mfa-policy-api-smoke.txt" \ diff --git a/README.md b/README.md index f487a6e494e..5c09922ff3a 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,62 @@ Rancher 1.6 ecosystem. It is not affiliated with Rancher Labs or SUSE. This fork of [`rancher/rancher`](https://github.com/rancher/rancher) preserves upstream history, authorship, licenses, and notices. -## Current release +## Current source + +Server `v1.6.519` is an unpublished candidate, packaging Web Console `1.6.181` +and Orchestration Engine `v0.183.335`. API Keys support full, custom and closed +policies, optional expiry, and key-scoped durable audit records. Their permissions +remain bounded by the owner's current role and environment access. Delegated +terminal/log access requires the verified Host API audit capability and the +compatible Linux Node Agent lifecycle. +Custom Key Project targets share the normal API's live membership checks; +membership never bypasses Key rules or unrelated resource authorization. -[Server v1.6.518](https://github.com/PastureStack/server/releases/tag/v1.6.518) -includes Web Console `1.6.180` and Orchestration Engine `v0.183.333`. The console supports OIDC, TOTP and passkeys, role-aware resource operations, container resource/hardware settings, and movable terminal/log windows. Hardware options require compatible node software and actual host capabilities; -GPU device access is not exclusive GPU allocation. +GPU device access is not exclusive GPU allocation. Audit queries and exports +cover retained records with environment-aware access and bounded pagination. +Explicit date bounds remain attached to every audit page, so an older interval +does not scan newer records first. Oversized queries retain the console filters +and offer explicit narrowing or retry. Export failures are translated on the +page instead of downloaded as error files. An export failure keeps successful +query results intact; scan and export limits are retained. +The Key modal owns initial focus and yields to keyboard or pointer interaction. +Audit export closes its own menu before locking the request, allowing consecutive +JSON, CSV and XLSX downloads without leaving a disabled menu open. +Key scope choices use the viewer's authorized environment list. Environment, +stack and resource selectors close before dependent loading or verification. +See the [candidate release note](docs/releases/server-1.6.519.md) for exact +component identities and verification boundaries. A version or packaged feature +does not establish that every resource/role/hardware combination has been tested. + +## API Keys in the source candidate + +Choose allow-by-default with deny exceptions, or deny-by-default with allow +exceptions, with optional expiry. Without exceptions these retain full or closed +access. Search for resource names; the editor and reviewed changes include a +resource-by-operation policy matrix, not an extra grant of authority. The maximum access +is always the Key owner's current account/environment RBAC; a Key never grants +an additional role. Existing legacy Keys are not automatically narrowed on upgrade. +New Key secrets are shown once at creation and are not returned by later lists or +details. Per-Key audit separates authorization, the HTTP response and any later +job/stream completion. Environment-Key audit rechecks live Engine access in +Engine-verified project contexts; a caller-supplied project header is not a grant. +Open a Key's audit from its action menu; the close control follows the audit +filters, results and details, below the audit block. +Scope options and unselected hints use readable light/dark theme text, including +keyboard highlighting; narrow layouts keep the matrix in its own scroll region. +Errors use the existing API status/code and console error +handling. See the [API Key guide](docs/api-keys.md) for policy and audit contracts. + +## Current release -The current release packages Metadata-driven CNI configuration migration fixes -and all-retained-time audit queries and exports. Authentication and -authorization contracts are retained. See the -[release note](docs/releases/server-1.6.518.md) for component identities, -verification results, and known limits. A published image is not a claim that -every resource/role/hardware combination has been tested. +The currently downloadable [Server v1.6.518](https://github.com/PastureStack/server/releases/tag/v1.6.518) +includes Web Console `1.6.180` and Orchestration Engine `v0.183.333`. +The quick-start examples below use this published image until the candidate is +verified and separately released. Its exact artifact identity and verification +boundaries are recorded in the [published release note](docs/releases/server-1.6.518.md). ## Quick start @@ -78,6 +119,11 @@ by default and requires explicit apply/rollback. Keep the previous image and backup: after a database migration, rollback may require restoring matching data, not merely selecting an older image tag. +After using API Key policy or expiry, do not downgrade directly to a +policy-unaware Engine: an older version can treat a restricted or expired Key +as full access. Follow the [API Key rollback safety guide](docs/upgrades/api-key-policy-rollback.md) +before any downgrade or database restore. + [GitHub Releases](https://github.com/PastureStack/server/releases) contain versioned assets, SHA-256 checksums, and release records. Built-in templates come from pinned [`catalog-templates`](https://github.com/PastureStack/catalog-templates) @@ -97,6 +143,14 @@ Runtime, database migration, registration, backup/restore, and upgrade validatio use isolated hosts. Publication is a separate, manually dispatched workflow. See [ORIGIN.md](ORIGIN.md) and [SECURITY.md](SECURITY.md). +The component candidate build uses the same release Dockerfile and verification +gates. `server/build-component-candidate.sh` requires actual component commits +and SHA-256 values; it does not publish or deploy. Set +`PASTURESTACK_COMPONENT_ARTIFACT_DIR` to an isolated directory with the five +component assets for an unpublished candidate. Without it, the same hashes +verify the versioned HTTPS release assets. The current published release remains +`v1.6.518` until a candidate is verified and separately released. + ## Language and licensing The console packages thirteen locales, including Traditional Chinese for Taiwan. diff --git a/docs/api-keys.md b/docs/api-keys.md new file mode 100644 index 00000000000..be6c909a875 --- /dev/null +++ b/docs/api-keys.md @@ -0,0 +1,85 @@ +# API Keys + +This guide describes the Server `v1.6.519` source candidate, with Engine +`0.183.335` and Web Console `1.6.181`. It is not a publication or a claim that +every role/resource combination has passed acceptance. Use the published numeric +image tag in the installation guide until the candidate is separately released. + +## Access and expiry + +Full retains the owner's existing account/environment scope. Closed allows no +Key-authorized operations. Custom narrows access by stable environment, Stack or +resource IDs and explicit operations; an explicit deny takes precedence. +In the console, select resources by searchable names and readable context, +not by entering IDs. Choose allow-by-default with deny exceptions (blacklist), +or deny-by-default with allow exceptions (whitelist). Both allow direct exception +editing, and changing the default preserves those exceptions. With no exceptions, +the original full or closed behavior applies. +Every policy is intersected with the owner's current RBAC. It neither snapshots +nor adds a role. Browser operation choices are not proof of backend authority. +Untouched legacy Keys are not automatically narrowed during an upgrade. + +The editor and reviewed changes show the same resource-by-operation policy +matrix. It explains deny precedence, partial scopes, unresolved ancestry and +expiry, with readable resource names. This is a Key-rule preview, not a guarantee +of effective authorization: live owner access, creation destinations and all +targets of multi-resource actions remain server-checked. An expired reviewed +policy cannot be submitted even if MFA confirmation arrives later. + +Policy-bearing writes are checked after authentication and before schema +sanitization. Forbidden owner or credential fields are rejected rather than +silently discarded into an accepted policy change. Ordinary legacy writes +without policy input retain their existing schema behavior. The sensitive +`secretValues` and `dbdump` links require the `export` operation, not `read`; +this classification does not add a restriction to full Keys' existing RBAC. + +Optional expiry uses a UTC timestamp. Policy changes use the stored revision; +broadening access or extending validity requires the existing platform MFA +confirmation bound to the operator and exact reviewed change. A stale revision +or failed confirmation uses the existing error contract. Refresh and review the +current Key instead of bypassing the conflict or reusing a confirmation. + +## Secret and per-Key audit + +A new secret is delivered once through the normal creation dialog. Store it +securely; later GET/list/detail views do not return it. Audit contains safe +identifiers and status metadata, not Key secrets or authentication material. + +Open audit from the Key's normal entry point. Access is checked against the +current viewer, including for an inactive Key. Environment-Key visibility is +verified in project contexts returned by the Engine for that viewer. A browser +project header does not grant access. Session or membership loss during the +query rejects the result instead of returning partially authorized records. +All-time has no hidden 24-hour +restriction; explicit UTC ranges and filters apply before bounded totals and +pagination. Failed queries or loss of access must not leave old records visible. + +Read decision, outcome and phase separately. ALLOW does not imply success; an +HTTP 200/202 response is not later job or stream completion. A response error +such as 405 is not automatically a Key permission denial. Only the actual +terminal event establishes completion. Container targets use their persisted +instance ID (`1i` prefix) consistently across decision, response and terminal +records, even when an agent schema has no `container` alias. Errors retain the +existing API status/code/request-id and console handling; no alternate login, +role override or secret recovery path is introduced. + +## Integration and recovery + +Delegated Linux streams require Host API `0.38.5`, Node Agent `0.13.28` and +WebSocket Proxy `0.23.15`. Windows retains its existing `0.13.27` compatibility +path; these Linux versions do not establish Windows delegation support. + +An expired, signed Key-traced stream ticket cannot start a backend or persistent +session. Its verified trace and authenticated host proof are accepted only for +audit, never to restore execution authority. After durable acceptance, the +handshake returns `ApiKeyExpired` (401) and records `DENY`/`FAILED`, phase +`handshake`; this is not an executor completion. If proof or durable acceptance +is unavailable, it returns `AuditUnavailable` (503), without starting execution +or claiming that the denial was durably recorded. + +See the [candidate contract and component boundary](releases/server-1.6.519.md). +Deploy the complete compatible Server image and preserve existing volumes and +backups. Do not downgrade restricted or expired Keys to a policy-unaware Engine, +or convert them to unrestricted legacy Keys as a workaround. Follow the +[API Key rollback safety guide](upgrades/api-key-policy-rollback.md) before any +downgrade or database restore. diff --git a/docs/releases/server-1.6.519.md b/docs/releases/server-1.6.519.md new file mode 100644 index 00000000000..591b90cda27 --- /dev/null +++ b/docs/releases/server-1.6.519.md @@ -0,0 +1,161 @@ +# Server v1.6.519 + +Status: unpublished candidate. No registry digest or new artifact-scan result +is claimed by this source document. + +Engine 0.183.335 adds a shared live Project-membership authority check before +custom Key evaluation. It corrects a legitimate member's Project being rejected +before the normal resource manager could authorize it. Key deny rules, expiry, +non-Project resource permissions and background authorization remain independent. +The previous 0.183.334 component is superseded for this unpublished candidate. + +## Scope + +Restricted and expiring API Keys use the same Engine authorization boundary +as full keys, intersected with the owner's live role and environment access. +Operation decisions and actual request/process outcomes are durably audited +without credential secrets or request payloads. Key-scoped audit queries +recheck the viewer's current authorization before filtering and pagination. +Transparent resource-manager filters preserve guarded collection SQL scope +before pagination; unknown or replaced query paths remain denied. During a +service upgrade, persisted live `upgrade=true, managed=false` relationships +retain their same-account ancestry while old instances are replaced. Ordinary +unmanaged, removed or contradictory relationships do not grant that ancestry. +The Key query uses the viewer's personal context and Engine-proven live project +contexts, then deduplicates and scopes events within a shared scan bound. +An incomplete live project-authority page is rejected without partial results. +Environment-Key metadata uses those Engine-proven contexts when a global Key +lookup is forbidden; each scoped lookup independently verifies the Key owner +and current viewer. Redirects, changed principals and lost memberships reject +the query rather than yielding partial records or trusting a caller header. +Delegated terminal/log access rechecks current authorization; terminal outcomes +are accepted only from the authenticated host's agent, with durable deduplication. +An API Key stream cannot start against a backend without the verified audit +capability. That blocked handshake is recorded as `FAILED`, phase `handshake`, +HTTP `503`; it is not an executor completion. Cookie-based access is unchanged. +A signed ticket used for a different stream route is rejected and durably +recorded as `DENY`/`FAILED`, phase `handshake`, HTTP `403`. An unavailable durable +receipt stops the stream with `503`; it does not start an executor. + +An expired, signed Key-traced ticket is rejected before backend or persistent +session creation. Its verified trace and authenticated host proof are audit-only: +durable acceptance records `DENY`/`FAILED`, phase `handshake`, and returns +`ApiKeyExpired` (`401`). Signature-only parsing never restores authorization. +Missing proof or durable acceptance returns `AuditUnavailable` (`503`), without +execution or a claim that the denial was durably recorded. The +[API Key guide](../api-keys.md) describes raw policy-input validation, sensitive +export classification and persisted container audit identity. + +The Server broker is a thin authenticated entry point. Engine owns the final +policy. Existing audit `timeScope=all`, bounded query/export behavior, database +retention, HAProxy, OIDC, and security overlays are retained. Explicit audit +date bounds and viewer filters survive Engine pagination. Lower and upper +bounds on the same field remain conjunctive; UTC, explicit offsets and +fractional seconds retain their timestamp meaning. Historical intervals +are bounded at the Engine rather than scanning later records first; genuinely +oversized queries still fail at the existing scan/export limits. The console +keeps the failed query's filters, stops polling, and offers explicit recovery +with translated errors. Checked exports preserve CSRF and same-origin cookies; +stale query/session responses cannot replace a newer view or deliver its file. +Narrow screen filter actions wrap, and audit errors use readable theme colors. +Export-only permission or download failures identify the failed export and keep +the successful query results intact. +The Key modal is the sole initial-focus owner; its shared name form opts out. +Keyboard or pointer interaction cancels delayed focus, including an ABA return +to the original active element. Teardown cancels only the component's own work. +Audit export hides its own existing dropdown before disabling the request +control, preserving consecutive JSON, CSV and XLSX selection. Related Chrome +tests pass; native acceptance of this new production bundle remains separate. +The govc bundle +uses x/text `0.41.0` for +[GO-2026-6629](https://pkg.go.dev/vuln/GO-2026-6629); its upstream Go source and +Go producer toolchain is now pinned to `1.27.2`, as are the other active Go +component producers. Historical base-image provenance is not rewritten. + +Linux Node Agent `0.13.28` supervises the installed Host API `0.38.5` binary +instead of the old embedded backend. Host and Node producer archives are +installed through the existing authenticated config-item SHA-256 verification +path. A missing or incorrect Host version does not fall back to the old backend. +The existing full bootstrap image `v1.2.31` remains pinned; this does not claim +that existing hosts have been upgraded merely because Server contains the new +archives. Windows retains its separate `0.13.27` package and unsupported +delegation boundary. + +The Linux Host completion spool validates each directory descriptor before +creating descendants. Unsafe aliases, writable or foreign-owned parents fail +without creating directories through them. Search-only trusted ancestors and +owner-only permission repair remain supported without changing parent modes. + +## Exact component inputs + +| Component | Clean source commit | Archive SHA-256 | +| --- | --- | --- | +| Engine `v0.183.335` (`cattle.jar`) | `8fd5e7d3021507647064da1ae9c9c3a6eeb4c725` | `98a665f59bced8f7c084e786280100e0571390b04eeffad140e633b463d8a204` | +| Web Console `1.6.181` | `7f2bdad0c90a3241693c2ddd4475202ababba550` | `9199681f2c4311f3fbe8727fcf0f6fa136c9bafe9fbe9d13878c55df442a7a03` | +| Catalog Service `0.20.13` | `4c39c73a8131ba06e9ff0aaec3b95cc27e049324` | `29626181cb8489b016e5975ffaddc00b2a32d290b1c0066e833d27fa7a5edf83` | +| Authentication Service `0.4.43` | `cae736f377019bd9743648a8e9aa469a0e21b5e0` | `e9218771af8dd68323c8c6fdab149c40a3ad02da9ff23f8aad6f0b2977740273` | +| Compose Executor `0.14.37` | `88e991e823f06d2334c07d5370595aae3c48ee99` | `5ff465601930218f531885a384f231d969033e4cef794ce33d16bfe24e5c1c81` | +| Host Provisioner `0.39.8` | `385e5b536c108f17fdfcdec84c01750a5be5ab1c` | `d775f36a613b1a486a5e60d6ad61fdbd1ebb4bd22422cbb6dcb70fdd7c4abf0c` | +| Secret Delivery API `0.3.2` | `f9c3f933f14e43dc63074d45a3044b693ec1e573` | `ca9ab0bfddbcad84b6c1a865af8ad82fcc56cb43dcca1da240ed11a5d24cbdb2` | +| Usage Telemetry Agent `0.4.2` | `40f9af7ca932fedacdb87e30b4ef1c60a4a7444e` | `5ce031c84f76b3e62dafdb04fb4ed014aa1921e83be056c2d5dd712acbce25a8` | +| Webhook Automation Service `0.10.4` | `400118b893843d2a7d7c65cc70c3449d76c4a8d8` | `49c4579829a04e758045fae02a5a9fca12bb0ba3af0d5e979cf9eb97f23a88a9` | +| Websocket Proxy `0.23.15` | `1928f602b66443cdab40c8cdb450c811548d2749` | `4657338973f672f6ae4d6e5510d06e811b9951afea1baa27a9caa3034e487f2a` | +| Host API `0.38.5` | `84754fe8bc79027d0c72f492923c47861aa74b99` | `ba111038695aa03a82c51d60944bbbdbbec30bb269051ba29fa21d46ada17179` | +| Linux Node Agent `0.13.28` | `bb4e057c089e5384bc9836b42db206326c690387` | `a16ae6be5fb72fa2ba38bd1076fd3b9e0536c48e70995ffa49d17b8063e2f8d0` | +| vSphere CLI Bundle `0.55.3` | `f48ab9fd9990132c85845fc162186a04f1e0418d` | `31be702e515741686e2c665d387562e5e993c5d2ffbdb2d614ed287243b166e4` | + +Web Console is built and officially packed from the listed source. Its custom +scope editor uses stable rule identity and defers capability publication until +after rendering. Reloading a newer policy revision obtains fresh names and +capabilities, discarding stale request and publication results. Source/render +regression tests are distinct from the required +native browser acceptance of this new production bundle. Blacklist/whitelist +defaults support direct exception editing; draft and reviewed changes include +the same named resource-by-operation policy matrix. Unknown or contradictory +ancestry is not guessed as permission. The preview does not grant owner access. +All 123 official package files match the production build byte-for-byte. +The scope selector uses the shared Projects service's live role-aware collection; +ordinary viewers do not acquire the administrator's `all=true` query. Environment, +stack and resource selection close the current menu before dependent loading. +Deferred completion, withdrawn authority and component teardown are covered by +92 focused Chrome tests, distinct from assembled-image native acceptance. + +Host package root: `84754fe8bc79027d0c72f492923c4786`. +Host binary SHA-256: `fda5080d6cfec0a4c1e9daf15b43cea2dce7ffb116c8292ff0f3547c8fb371f7`. +Host `apply.sh` SHA-256: `8a21f63099832afb571755011bbcf8d97710994a50c419b20700cbc31efced0f`. +Proxy binary SHA-256: `9111d5a569b6326d7cd71fc3384251d9684972492a22e1e9dbbb9863019fbaaa`. +govc binary SHA-256: `d3c4f4fab44403ec4110743b52da99f5ce3d7e3773c4661db8a87dec3ead8990`. +Node package root: `bb4e057c089e5384bc9836b42db20632`. +Node binary SHA-256: `ce8342e10da07c50cc414b471cadc67db4dd390e6e2dcb632b30c542f658b021`. +Node `apply.sh` SHA-256: `dd8cb342518a43e7468cc121db5c4e44b16731f2620b77ea0fd03c02c03766a9`. + +The new Host producer package is verified and installed byte-for-byte; +the compatibility repair path is limited to the historical `0.38.4` package. +Candidate local inputs and later HTTPS release assets use the same thirteen +archive checksums and formal Dockerfile. Missing identities, extra input files, +symlinks and checksum failures stop the build. + +## Verification boundary + +### Rollback safety + +Direct image-only downgrade to Server `1.6.518` or another policy-unaware Engine +is unsupported. Such versions can interpret custom, closed or expired API Keys +as full access. Recover to a compatible version, or disable the external API +while revoking affected Keys and preserving revocation tombstones. Restoring a +database must not resurrect credentials revoked, narrowed or expired since the +backup. See the [API Key rollback safety guide](../upgrades/api-key-policy-rollback.md) +for the required recovery boundary. + +### Artifact evidence + +Source/recipe tests, exact producer package checks and Engine component tests +are distinct from the assembled Server image's startup, restart, feature/API +matrix and merged-runtime vulnerability scan. Publication requires those +artifact-specific checks; a source commit or inherited VEX statement is not +evidence that the new assembled image passed them. + +The existing VEX assessments, vendor-pending findings and review deadlines +are retained without relaxing the release policy or claiming a new scan. +The published installation target remains `v1.6.518` until the candidate is +verified and separately published. diff --git a/docs/upgrades/api-key-policy-rollback.md b/docs/upgrades/api-key-policy-rollback.md new file mode 100644 index 00000000000..0dc583c1010 --- /dev/null +++ b/docs/upgrades/api-key-policy-rollback.md @@ -0,0 +1,29 @@ +# API Key policy rollback safety + +Server 519 / Engine 334 enforce API Key policy, expiry and live owner access. +Server 518 and other policy-unaware Engines do not understand those limits. +An image-only downgrade is unsupported: a custom, closed or expired Key can +otherwise regain full access under the older credential interpretation. + +Prefer recovery to a compatible policy-aware version. If that is not possible, +keep the external API disabled or isolated while preparing the recovery. Do +not expose a policy-unaware Engine merely because startup or database restore +succeeds. + +Before exposing an older Engine, revoke every affected credential through a +compatible version and retain the revocation tombstones. Include restricted, +closed and expired Keys, and credentials revoked or narrowed after the proposed +backup was taken. Do not convert their policies to full access as a downgrade +workaround. A previously issued secret must remain unusable after revocation. + +A database restore must not resurrect revoked, narrowed or expired credentials. +Retain and reconcile the current revocation state before serving requests; +an old backup alone is not sufficient evidence that this is safe. If the +restored version or data cannot preserve that state, leave external API access +disabled and recover to a compatible version. Validate affected credentials +are denied before re-enabling access, and issue replacements only through the +normal authenticated management flow. + +Keep the matching database/volume backups and image recovery point. This guide +does not authorize a production downgrade, database rewrite or deletion of +credential/audit history. diff --git a/scripts/check-server-api-explorer-patch.sh b/scripts/check-server-api-explorer-patch.sh index 6efcd08b349..a6c5bd05107 100755 --- a/scripts/check-server-api-explorer-patch.sh +++ b/scripts/check-server-api-explorer-patch.sh @@ -491,7 +491,10 @@ require_marker "$release_dockerfile" \ 'ARG BASE_IMAGE=ghcr.io/pasturestack/server:v1.6.460@sha256:c855af8aea232dacc5bb6df68e2271d482c68b53c43ab0c108ec19118f5ab403' \ SERVER_INCREMENTAL_RELEASE_BASE_MISSING require_marker "$release_dockerfile" \ - 'org.opencontainers.image.version="v1.6.518"' \ + 'ARG SERVER_RELEASE_TAG=v1.6.519' \ + SERVER_INCREMENTAL_RELEASE_DEFAULT_MISSING +require_marker "$release_dockerfile" \ + 'org.opencontainers.image.version="${SERVER_RELEASE_TAG}"' \ SERVER_INCREMENTAL_RELEASE_VERSION_MISSING require_marker "$release_dockerfile" \ 'org.opencontainers.image.base.name="ghcr.io/pasturestack/server:v1.6.460"' \ @@ -500,10 +503,10 @@ require_marker "$release_dockerfile" \ 'org.opencontainers.image.base.digest="sha256:c855af8aea232dacc5bb6df68e2271d482c68b53c43ab0c108ec19118f5ab403"' \ SERVER_INCREMENTAL_RELEASE_BASE_DIGEST_MISSING require_marker "$release_dockerfile" \ - 'ENV CATTLE_RANCHER_SERVER_VERSION=v1.6.518' \ + 'ENV CATTLE_RANCHER_SERVER_VERSION=${SERVER_RELEASE_TAG}' \ SERVER_INCREMENTAL_RELEASE_RUNTIME_VERSION_MISSING require_marker "$release_dockerfile" \ - 'COPY --from=release_artifacts /out/host-api-0.38.4.tar.gz /usr/share/cattle/artifacts/host-api-0.38.4.tar.gz' \ + 'COPY --from=release_artifacts /out/host-api.tar.gz /usr/share/cattle/artifacts/host-api-${HOST_API_VERSION}.tar.gz' \ SERVER_HOST_API_SHA256_PACKAGE_MISSING require_marker "$release_dockerfile" \ 'repair-host-api-sha256' \ @@ -511,17 +514,34 @@ require_marker "$release_dockerfile" \ require_marker "$publish_workflow" \ 'bash source/scripts/check-server-host-api-package.sh' \ SERVER_HOST_API_RELEASE_CHECK_MISSING +for release_host_marker in \ + 'ARG HOST_API_VERSION=0.38.5' \ + 'ARG HOST_API_PACKAGE_MODE=producer' \ + 'ARG HOST_API_RELEASE_BASE_URL=https://github.com/PastureStack/host-api/releases/download' \ + 'ARG HOST_API_RELEASE_TAG=v0.38.5' \ + 'ARG HOST_API_COMMIT=84754fe8bc79027d0c72f492923c47861aa74b99' \ + 'ARG HOST_API_PACKAGE_ID=84754fe8bc79027d0c72f492923c4786' \ + 'ARG HOST_API_ARCHIVE_SHA256=ba111038695aa03a82c51d60944bbbdbbec30bb269051ba29fa21d46ada17179' \ + 'ARG HOST_API_BINARY_SHA256=fda5080d6cfec0a4c1e9daf15b43cea2dce7ffb116c8292ff0f3547c8fb371f7' \ + 'ARG HOST_API_APPLY_SHA256=8a21f63099832afb571755011bbcf8d97710994a50c419b20700cbc31efced0f' \ + 'verify-host-api-package /out/host-api.tar.gz'; do + require_marker "$release_dockerfile" "$release_host_marker" \ + SERVER_HOST_API_PRODUCER_IDENTITY_MISSING +done +require_marker "$publish_workflow" \ + 'bash source/server/artifacts/verify-host-api-package.sh' \ + SERVER_HOST_API_PRODUCER_RELEASE_CHECK_MISSING require_marker "$release_dockerfile" \ - 'ARG WEB_CONSOLE_RELEASE_TAG=1.6.180' \ + 'ARG WEB_CONSOLE_RELEASE_TAG=1.6.181' \ SERVER_INCREMENTAL_WEB_CONSOLE_VERSION_MISSING require_marker "$release_dockerfile" \ - 'ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.180.tar.gz' \ + 'ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.181.tar.gz' \ SERVER_INCREMENTAL_WEB_CONSOLE_ARTIFACT_MISSING require_marker "$release_dockerfile" \ - 'ARG WEB_CONSOLE_ARTIFACT_SHA256=a367bd6907281298a8e2bf0f3ad0444083db06062f3a1521db573cc5606d274e' \ + 'ARG WEB_CONSOLE_ARTIFACT_SHA256=9199681f2c4311f3fbe8727fcf0f6fa136c9bafe9fbe9d13878c55df442a7a03' \ SERVER_INCREMENTAL_WEB_CONSOLE_HASH_MISSING require_marker "$release_dockerfile" \ - 'ARG WEB_CONSOLE_COMMIT=637604b38401b19d2c9ef73d5729d356bb80c8e6' \ + 'ARG WEB_CONSOLE_COMMIT=7f2bdad0c90a3241693c2ddd4475202ababba550' \ SERVER_INCREMENTAL_WEB_CONSOLE_COMMIT_MISSING require_marker "$release_dockerfile" \ "grep -aF 'hostsPage.permissionDenied'" \ @@ -530,46 +550,46 @@ require_marker "$release_dockerfile" \ '"hostsPage.permissionDenied":"您沒有權限在此環境中新增主機。"' \ SERVER_INCREMENTAL_WEB_CONSOLE_ZH_TW_PERMISSION_MESSAGE_MISSING require_marker "$build_script" \ - 'web_console_commit=${WEB_CONSOLE_COMMIT:-637604b38401b19d2c9ef73d5729d356bb80c8e6}' \ + 'web_console_commit=${WEB_CONSOLE_COMMIT:-7f2bdad0c90a3241693c2ddd4475202ababba550}' \ SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_COMMIT_MISSING require_marker "$build_script" \ - 'web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.180}' \ + 'web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.181}' \ SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_VERSION_MISSING require_marker "$build_script" \ - 'web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.180.tar.gz}' \ + 'web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.181.tar.gz}' \ SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_ARTIFACT_MISSING require_marker "$build_script" \ - 'web_console_artifact_sha256=${WEB_CONSOLE_ARTIFACT_SHA256:-a367bd6907281298a8e2bf0f3ad0444083db06062f3a1521db573cc5606d274e}' \ + 'web_console_artifact_sha256=${WEB_CONSOLE_ARTIFACT_SHA256:-9199681f2c4311f3fbe8727fcf0f6fa136c9bafe9fbe9d13878c55df442a7a03}' \ SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_HASH_MISSING require_marker "$release_dockerfile" \ - 'ARG CATALOG_SERVICE_VERSION=0.20.12' \ + 'ARG CATALOG_SERVICE_VERSION=0.20.13' \ SERVER_INCREMENTAL_CATALOG_VERSION_MISSING require_marker "$build_script" \ - 'catalog_service_version=${CATALOG_SERVICE_VERSION:-0.20.12}' \ + 'catalog_service_version=${CATALOG_SERVICE_VERSION:-0.20.13}' \ SERVER_INCREMENTAL_CATALOG_BUILD_VERSION_MISSING require_marker "$release_dockerfile" \ - 'ARG CATALOG_SERVICE_COMMIT=d708579092eae0fd03b2750ac594ff0396cf563b' \ + 'ARG CATALOG_SERVICE_COMMIT=4c39c73a8131ba06e9ff0aaec3b95cc27e049324' \ SERVER_INCREMENTAL_CATALOG_COMMIT_MISSING require_marker "$build_script" \ - 'catalog_service_commit=${CATALOG_SERVICE_COMMIT:-d708579092eae0fd03b2750ac594ff0396cf563b}' \ + 'catalog_service_commit=${CATALOG_SERVICE_COMMIT:-4c39c73a8131ba06e9ff0aaec3b95cc27e049324}' \ SERVER_INCREMENTAL_CATALOG_BUILD_COMMIT_MISSING require_marker "$release_dockerfile" \ - 'ARG CATALOG_SERVICE_ARCHIVE_SHA256=34b76c121270c603501664f7146d41916c7f45da983e326861ed4c5b9614372d' \ + 'ARG CATALOG_SERVICE_ARCHIVE_SHA256=29626181cb8489b016e5975ffaddc00b2a32d290b1c0066e833d27fa7a5edf83' \ SERVER_INCREMENTAL_CATALOG_ARCHIVE_SHA256_MISSING require_marker "$build_script" \ - 'catalog_service_archive_sha256=${CATALOG_SERVICE_ARCHIVE_SHA256:-34b76c121270c603501664f7146d41916c7f45da983e326861ed4c5b9614372d}' \ + 'catalog_service_archive_sha256=${CATALOG_SERVICE_ARCHIVE_SHA256:-29626181cb8489b016e5975ffaddc00b2a32d290b1c0066e833d27fa7a5edf83}' \ SERVER_INCREMENTAL_CATALOG_BUILD_ARCHIVE_SHA256_MISSING require_marker "$release_dockerfile" \ - 'ARG CATALOG_SERVICE_BINARY_SHA256=3deb43f9760d7cbb07f818dd108ab35abf9efc6908d5d7fcaf5c44a185f2fba9' \ + 'ARG CATALOG_SERVICE_BINARY_SHA256=7224c76e5643130dee047e3f1888ce6845d024307bec74d72eebdc4baeae1aea' \ SERVER_INCREMENTAL_CATALOG_BINARY_SHA256_MISSING require_marker "$build_script" \ - 'catalog_service_binary_sha256=${CATALOG_SERVICE_BINARY_SHA256:-3deb43f9760d7cbb07f818dd108ab35abf9efc6908d5d7fcaf5c44a185f2fba9}' \ + 'catalog_service_binary_sha256=${CATALOG_SERVICE_BINARY_SHA256:-7224c76e5643130dee047e3f1888ce6845d024307bec74d72eebdc4baeae1aea}' \ SERVER_INCREMENTAL_CATALOG_BUILD_BINARY_SHA256_MISSING require_marker "$release_dockerfile" \ - 'ARG CATALOG_SERVICE_SQLITE_BINARY_SHA256=8805af3c0b5968a02f994d65de715c525b73637aa2dc398a0648feeeaf2fd397' \ + 'ARG CATALOG_SERVICE_SQLITE_BINARY_SHA256=4aeda3e1ee1ca4c57f26938f3ef27651f9c8c129ee12957642960d22b95034ce' \ SERVER_INCREMENTAL_CATALOG_SQLITE_BINARY_SHA256_MISSING require_marker "$build_script" \ - 'catalog_service_sqlite_binary_sha256=${CATALOG_SERVICE_SQLITE_BINARY_SHA256:-8805af3c0b5968a02f994d65de715c525b73637aa2dc398a0648feeeaf2fd397}' \ + 'catalog_service_sqlite_binary_sha256=${CATALOG_SERVICE_SQLITE_BINARY_SHA256:-4aeda3e1ee1ca4c57f26938f3ef27651f9c8c129ee12957642960d22b95034ce}' \ SERVER_INCREMENTAL_CATALOG_BUILD_SQLITE_BINARY_SHA256_MISSING require_marker "$release_dockerfile" \ 'ARG CATALOG_SERVICE_LICENSE_SHA256=0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594' \ @@ -578,14 +598,17 @@ require_marker "$build_script" \ 'catalog_service_license_sha256=${CATALOG_SERVICE_LICENSE_SHA256:-0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594}' \ SERVER_INCREMENTAL_CATALOG_BUILD_LICENSE_SHA256_MISSING for catalog_release_marker in \ + 'COPY --chmod=0755 artifacts/fetch-server-component.sh /usr/local/bin/fetch-server-component' \ + '"catalog-service-${CATALOG_SERVICE_VERSION}.tar.xz" "${CATALOG_SERVICE_ARCHIVE_SHA256}" "${catalog_archive}";' \ + '"catalog-service-${CATALOG_SERVICE_VERSION}-LICENSE.txt" "${CATALOG_SERVICE_LICENSE_SHA256}" /out/catalog-service-licenses/LICENSE.txt;' \ 'for catalog_hash in "${CATALOG_SERVICE_ARCHIVE_SHA256}" "${CATALOG_SERVICE_BINARY_SHA256}" "${CATALOG_SERVICE_SQLITE_BINARY_SHA256}" "${CATALOG_SERVICE_LICENSE_SHA256}"; do' \ 'printf '"'"'./\n./catalog-service\n./catalog-service-sqlite\n'"'"' | cmp - "${catalog_listing}";' \ 'tar --no-same-owner --no-same-permissions -xJf "${catalog_archive}" -C /out/catalog-service' \ 'go version -m "/out/catalog-service/${catalog_binary}" | grep -F "vcs.revision=${CATALOG_SERVICE_COMMIT}"' \ 'go version -m "/out/catalog-service/${catalog_binary}" | grep -F '"'"'vcs.modified=false'"'"'' \ 'echo "${CATALOG_SERVICE_LICENSE_SHA256} /out/catalog-service-licenses/LICENSE.txt" | sha256sum -c -' \ - 'COPY --from=release_artifacts --chmod=0755 /out/catalog-service/catalog-service /usr/bin/catalog-service.real' \ - 'COPY --from=release_artifacts --chmod=0755 /out/catalog-service/catalog-service-sqlite /usr/bin/catalog-service-sqlite' \ + 'install -m 0755 /out/catalog-service/catalog-service /out/runtime-bin/catalog-service.real;' \ + 'install -m 0755 /out/catalog-service/catalog-service-sqlite /out/runtime-bin/catalog-service-sqlite;' \ 'echo "${CATALOG_SERVICE_BINARY_SHA256} /usr/bin/catalog-service.real" | sha256sum -c -' \ 'echo "${CATALOG_SERVICE_SQLITE_BINARY_SHA256} /usr/bin/catalog-service-sqlite" | sha256sum -c -' \ 'echo "${CATALOG_SERVICE_LICENSE_SHA256} /usr/share/licenses/pasturestack/catalog-service/LICENSE.txt" | sha256sum -c -' \ @@ -604,6 +627,147 @@ for catalog_build_marker in \ SERVER_INCREMENTAL_CATALOG_BUILD_CONTRACT_MISSING done +# Active incremental producer/toolchain contracts are distinct from the historical base patch below. +for incremental_producer_marker in \ + 'ARG HOST_PROVISIONER_VERSION=0.39.8' \ + 'ARG HOST_PROVISIONER_COMMIT=385e5b536c108f17fdfcdec84c01750a5be5ab1c' \ + 'ARG HOST_PROVISIONER_ARCHIVE_SHA256=d775f36a613b1a486a5e60d6ad61fdbd1ebb4bd22422cbb6dcb70fdd7c4abf0c' \ + 'ARG HOST_PROVISIONER_BINARY_SHA256=1d37e20a7a1cf4f3e36036a15ff7699ef22cd8809fd14a20892034dd054fd1fc' \ + 'ARG SECRET_DELIVERY_API_VERSION=0.3.2' \ + 'ARG SECRET_DELIVERY_API_COMMIT=f9c3f933f14e43dc63074d45a3044b693ec1e573' \ + 'ARG SECRET_DELIVERY_API_ARCHIVE_SHA256=ca9ab0bfddbcad84b6c1a865af8ad82fcc56cb43dcca1da240ed11a5d24cbdb2' \ + 'ARG SECRET_DELIVERY_API_BINARY_SHA256=b5e01b7e65aeee3b456fe043142b5e70f4039ee89ffd437648368ff63e6042b6' \ + 'ARG USAGE_TELEMETRY_AGENT_VERSION=0.4.2' \ + 'ARG USAGE_TELEMETRY_AGENT_COMMIT=40f9af7ca932fedacdb87e30b4ef1c60a4a7444e' \ + 'ARG USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256=5ce031c84f76b3e62dafdb04fb4ed014aa1921e83be056c2d5dd712acbce25a8' \ + 'ARG USAGE_TELEMETRY_AGENT_BINARY_SHA256=e62a21270142181315293d7e11482288ffe8fc4d91cc8fe07dacc90b461f3709' \ + 'ARG COMPOSE_EXECUTOR_COMMIT=88e991e823f06d2334c07d5370595aae3c48ee99' \ + 'ARG COMPOSE_EXECUTOR_ARCHIVE_SHA256=5ff465601930218f531885a384f231d969033e4cef794ce33d16bfe24e5c1c81'; do + require_marker "$release_dockerfile" "$incremental_producer_marker" \ + SERVER_INCREMENTAL_RUNTIME_PRODUCER_PIN_MISSING +done +for incremental_producer_build_marker in \ + 'host_provisioner_version=${HOST_PROVISIONER_VERSION:-0.39.8}' \ + 'host_provisioner_commit=${HOST_PROVISIONER_COMMIT:-385e5b536c108f17fdfcdec84c01750a5be5ab1c}' \ + 'host_provisioner_archive_sha256=${HOST_PROVISIONER_ARCHIVE_SHA256:-d775f36a613b1a486a5e60d6ad61fdbd1ebb4bd22422cbb6dcb70fdd7c4abf0c}' \ + 'host_provisioner_binary_sha256=${HOST_PROVISIONER_BINARY_SHA256:-1d37e20a7a1cf4f3e36036a15ff7699ef22cd8809fd14a20892034dd054fd1fc}' \ + 'secret_delivery_api_version=${SECRET_DELIVERY_API_VERSION:-0.3.2}' \ + 'secret_delivery_api_commit=${SECRET_DELIVERY_API_COMMIT:-f9c3f933f14e43dc63074d45a3044b693ec1e573}' \ + 'secret_delivery_api_archive_sha256=${SECRET_DELIVERY_API_ARCHIVE_SHA256:-ca9ab0bfddbcad84b6c1a865af8ad82fcc56cb43dcca1da240ed11a5d24cbdb2}' \ + 'secret_delivery_api_binary_sha256=${SECRET_DELIVERY_API_BINARY_SHA256:-b5e01b7e65aeee3b456fe043142b5e70f4039ee89ffd437648368ff63e6042b6}' \ + 'usage_telemetry_agent_version=${USAGE_TELEMETRY_AGENT_VERSION:-0.4.2}' \ + 'usage_telemetry_agent_commit=${USAGE_TELEMETRY_AGENT_COMMIT:-40f9af7ca932fedacdb87e30b4ef1c60a4a7444e}' \ + 'usage_telemetry_agent_archive_sha256=${USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256:-5ce031c84f76b3e62dafdb04fb4ed014aa1921e83be056c2d5dd712acbce25a8}' \ + 'usage_telemetry_agent_binary_sha256=${USAGE_TELEMETRY_AGENT_BINARY_SHA256:-e62a21270142181315293d7e11482288ffe8fc4d91cc8fe07dacc90b461f3709}' \ + 'compose_executor_version=${COMPOSE_EXECUTOR_VERSION:-0.14.37}' \ + 'compose_executor_commit=${COMPOSE_EXECUTOR_COMMIT:-88e991e823f06d2334c07d5370595aae3c48ee99}' \ + 'compose_executor_archive_sha256=${COMPOSE_EXECUTOR_ARCHIVE_SHA256:-5ff465601930218f531885a384f231d969033e4cef794ce33d16bfe24e5c1c81}' \ + 'compose_executor_binary_sha256=${COMPOSE_EXECUTOR_BINARY_SHA256:-a9bf9f0f77e914fe557d3e178a73c31526b0ca0adc4afbf17bf68d8d75c7ee27}'; do + require_marker "$build_script" "$incremental_producer_build_marker" \ + SERVER_INCREMENTAL_RUNTIME_PRODUCER_BUILD_PIN_MISSING +done +for incremental_producer_runtime_marker in \ + 'COPY --chmod=0755 artifacts/verify-runtime-producer.sh /usr/local/bin/verify-runtime-producer' \ + 'verify-runtime-producer "$component" "$version" "$commit" "/tmp/${artifact}" "$binary_sha" "/out/$component";' \ + 'install -m 0755 /out/host-provisioner/host-provisioner /out/runtime-bin/host-provisioner.real;' \ + 'cp -a /out/host-provisioner-licenses /out/runtime-licenses/host-provisioner;' \ + 'install -m 0755 /out/secret-delivery-api/secret-delivery-api /out/runtime-bin/secret-delivery-api;' \ + 'cp -a /out/secret-delivery-api-licenses /out/runtime-licenses/secret-delivery-api;' \ + 'install -m 0755 /out/usage-telemetry-agent/usage-telemetry-agent /out/runtime-bin/usage-telemetry-agent;' \ + 'cp -a /out/usage-telemetry-agent-licenses /out/runtime-licenses/usage-telemetry-agent;' \ + 'echo "${HOST_PROVISIONER_BINARY_SHA256} /usr/bin/host-provisioner.real" | sha256sum -c -;' \ + '/usr/bin/host-provisioner.real -v | grep -F "${HOST_PROVISIONER_VERSION}" >/dev/null;' \ + 'test -s /usr/share/licenses/pasturestack/host-provisioner/LICENSE;' \ + 'test -s /usr/share/licenses/pasturestack/host-provisioner/ORIGIN.md;' \ + 'test "$(find /usr/share/licenses/pasturestack/host-provisioner/licenses -type f | wc -l)" -eq 35;' \ + 'echo "${SECRET_DELIVERY_API_BINARY_SHA256} /usr/bin/secret-delivery-api" | sha256sum -c -;' \ + '/usr/bin/secret-delivery-api --version | grep -F "v${SECRET_DELIVERY_API_VERSION}" >/dev/null;' \ + 'test "$(readlink -f /usr/bin/secrets-api)" = /usr/bin/secret-delivery-api;' \ + 'echo "${USAGE_TELEMETRY_AGENT_BINARY_SHA256} /usr/bin/usage-telemetry-agent" | sha256sum -c -;' \ + '/usr/bin/usage-telemetry-agent --version | grep -F "usage-telemetry-agent ${USAGE_TELEMETRY_AGENT_VERSION} (" >/dev/null;' \ + 'test "$(readlink -f /usr/bin/telemetry)" = /usr/bin/usage-telemetry-agent;' \ + 'test ! -e "${license_dir}/${component}";' \ + 'grep -Fx '"'"'Go compiler: 1.27.2'"'"' "${license_dir}/SERVER-PRODUCER-SOURCES.txt" >/dev/null;' \ + 'test -s "${license_dir}/${component}-${suffix}";' \ + 'test -s /usr/share/licenses/pasturestack/usage-telemetry-agent/usage-telemetry-agent-PRIVACY.md;'; do + require_marker "$release_dockerfile" "$incremental_producer_runtime_marker" \ + SERVER_INCREMENTAL_RUNTIME_PRODUCER_GUARD_MISSING +done +for incremental_go_marker in \ + 'ARG ARTIFACT_HELPER_IMAGE=golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61' \ + 'test "$(go version | awk '"'"'{print $3}'"'"')" = go1.27.2;' \ + 'ENV PASTURESTACK_RUNTIME_GO_VERSION=1.27.2' \ + 'ENV PASTURESTACK_CONSOLE_BROKER_GO_VERSION=1.27.2' \ + 'ARG ENGINE_READONLY_SCHEMA_SHA256=7f274219e8dd9c6d750a408a6edec1448b564f4b16ba2204c425c5b45cec2233' \ + 'ARG ENGINE_RESTRICTED_SCHEMA_SHA256=f854ba99260f29e324ab7446a8592996946038920ac9bcf17689d98d1ebc5e51'; do + require_marker "$release_dockerfile" "$incremental_go_marker" \ + SERVER_INCREMENTAL_GO_OR_FROZEN_SCHEMA_MISSING +done +for incremental_go_build_marker in \ + 'PASTURESTACK_RUNTIME_GO_VERSION=1.27.2' \ + 'PASTURESTACK_CONSOLE_BROKER_GO_VERSION=1.27.2' \ + 'grep -aF "go1.27.2" "${binary}" >/dev/null' \ + 'runtime_go=1.27.2' \ + 'engine_readonly_schema_sha256=${ENGINE_READONLY_SCHEMA_SHA256:-7f274219e8dd9c6d750a408a6edec1448b564f4b16ba2204c425c5b45cec2233}' \ + 'engine_restricted_schema_sha256=${ENGINE_RESTRICTED_SCHEMA_SHA256:-f854ba99260f29e324ab7446a8592996946038920ac9bcf17689d98d1ebc5e51}'; do + require_marker "$build_script" "$incremental_go_build_marker" \ + SERVER_INCREMENTAL_GO_OR_FROZEN_SCHEMA_BUILD_MISSING +done + +for incremental_input_marker in \ + '"$orchestration_engine_artifact|$orchestration_engine_artifact_sha256"' \ + '"$web_console_artifact|$web_console_artifact_sha256"' \ + '"websocket-proxy-${websocket_proxy_version}-linux-amd64.tar.xz|$websocket_proxy_archive_sha256"' \ + '"host-api-${host_api_version}.tar.gz|$host_api_archive_sha256"' \ + '"node-agent-${node_agent_version}.tar.gz|$node_agent_archive_sha256"' \ + '"host-provisioner-${host_provisioner_version}-linux-amd64.tar.xz|$host_provisioner_archive_sha256"' \ + '"secret-delivery-api-${secret_delivery_api_version}-linux-amd64.tar.xz|$secret_delivery_api_archive_sha256"' \ + '"usage-telemetry-agent-${usage_telemetry_agent_version}-linux-amd64.tar.xz|$usage_telemetry_agent_archive_sha256"' \ + '"catalog-service-${catalog_service_version}.tar.xz|$catalog_service_archive_sha256"' \ + '"catalog-service-${catalog_service_version}-LICENSE.txt|$catalog_service_license_sha256"' \ + '"authentication-service-${authentication_service_version}-linux-amd64.tar.xz|$authentication_service_archive_sha256"' \ + '"webhook-automation-service-${webhook_automation_service_version}-linux-amd64.tar.xz|$webhook_automation_service_archive_sha256"' \ + '"compose-executor-${compose_executor_version}-linux-amd64.gz|$compose_executor_archive_sha256"' \ + '"vsphere-cli-bundle-${vsphere_cli_bundle_version}-linux-amd64.tar.xz|$vsphere_cli_bundle_archive_sha256"' \ + '[[ "$actual_components" == "$expected_components" ]]' \ + 'printf '"'"'%s %s\n'"'"' "$component_sha256" "$component_directory/$component_name" | sha256sum -c -'; do + require_marker "$build_script" "$incremental_input_marker" \ + SERVER_INCREMENTAL_EXACT_COMPONENT_INPUT_MISSING +done + +for incremental_layout_marker in \ + 'install -m 0755 /out/catalog-service/catalog-service /out/runtime-bin/catalog-service.real;' \ + 'install -m 0755 /out/catalog-service/catalog-service-sqlite /out/runtime-bin/catalog-service-sqlite;' \ + 'install -m 0755 /out/authentication-service/authentication-service /out/runtime-bin/authentication-service.real;' \ + 'install -m 0755 /out/websocket-proxy/websocket-proxy /out/runtime-bin/websocket-proxy.real;' \ + 'install -m 0755 /out/webhook-automation-service/webhook-automation-service /out/runtime-bin/webhook-automation-service;' \ + 'install -m 0755 /out/compose-executor /out/runtime-bin/compose-executor.real;' \ + 'install -m 0755 /out/vsphere-cli-bundle/govc /out/runtime-bin/govc;' \ + 'install -m 0755 /out/host-provisioner/host-provisioner /out/runtime-bin/host-provisioner.real;' \ + 'install -m 0755 /out/secret-delivery-api/secret-delivery-api /out/runtime-bin/secret-delivery-api;' \ + 'install -m 0755 /out/usage-telemetry-agent/usage-telemetry-agent /out/runtime-bin/usage-telemetry-agent;' \ + 'cp -a /out/catalog-service-licenses /out/runtime-licenses/catalog-service;' \ + 'cp -a /out/host-provisioner-licenses /out/runtime-licenses/host-provisioner;' \ + 'cp -a /out/secret-delivery-api-licenses /out/runtime-licenses/secret-delivery-api;' \ + 'cp -a /out/usage-telemetry-agent-licenses /out/runtime-licenses/usage-telemetry-agent;' \ + '/out/webhook-automation-service/webhook-automation-service-COMPATIBILITY.md' \ + '/out/webhook-automation-service/webhook-automation-service-LICENSES.txt' \ + '/out/webhook-automation-service/webhook-automation-service-SOURCES.txt' \ + '/out/webhook-automation-service/webhook-automation-service-THIRD-PARTY-NOTICES.md' \ + '/out/runtime-licenses/webhook-automation-service/' \ + '/out/vsphere-cli-bundle/vsphere-cli-bundle-LICENSES.txt' \ + '/out/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt' \ + '/out/vsphere-cli-bundle/vsphere-cli-bundle-THIRD-PARTY-NOTICES.txt' \ + '/out/runtime-licenses/vsphere-cli-bundle/' \ + 'COPY --from=release_artifacts --chmod=0755 /out/runtime-bin/ /usr/bin/' \ + 'COPY --from=release_artifacts /out/runtime-licenses/ /usr/share/licenses/pasturestack/' \ + 'test "$(find /out/runtime-bin -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 10;' \ + 'test "$(find /out/runtime-licenses -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 6;' \ + 'test -z "$(find /out/runtime-bin /out/runtime-licenses -type l -print -quit)"'; do + require_marker "$release_dockerfile" "$incremental_layout_marker" \ + SERVER_INCREMENTAL_RUNTIME_LAYOUT_MISSING +done + for validation_label in \ formNameDescription.name.label \ formNameDescription.description.label \ @@ -676,44 +840,53 @@ if grep -Eq 'dacf7353a1e72e933ac883c3e4c5521e12bb9017706475a7d94f2803c5b5b216|23 exit 1 fi for release_proxy_marker in \ - 'ARG WEBSOCKET_PROXY_VERSION=0.23.14' \ - 'ARG WEBSOCKET_PROXY_COMMIT=3b5788bdc52f4edab0097a3d97afccf138c64089' \ - 'ARG WEBSOCKET_PROXY_ARCHIVE_SHA256=c55108c3dbfd8e6579fc768a1988920db83c6f605ca1284b60edf92ae8d0160e' \ - 'ARG WEBSOCKET_PROXY_BINARY_SHA256=efd0c78779a620b4b0f74a10eb3f3edd8886e8d23f22dc4624d8e9971085a26d' \ + 'ARG WEBSOCKET_PROXY_VERSION=0.23.15' \ + 'ARG WEBSOCKET_PROXY_COMMIT=1928f602b66443cdab40c8cdb450c811548d2749' \ + 'ARG WEBSOCKET_PROXY_ARCHIVE_SHA256=4657338973f672f6ae4d6e5510d06e811b9951afea1baa27a9caa3034e487f2a' \ + 'ARG WEBSOCKET_PROXY_BINARY_SHA256=9111d5a569b6326d7cd71fc3384251d9684972492a22e1e9dbbb9863019fbaaa' \ 'tar --no-same-owner --no-same-permissions -xJf "${websocket_archive}"' \ - 'COPY --from=release_artifacts --chmod=0755 /out/websocket-proxy/websocket-proxy /usr/bin/websocket-proxy.real' \ + 'install -m 0755 /out/websocket-proxy/websocket-proxy /out/runtime-bin/websocket-proxy.real;' \ '/usr/bin/websocket-proxy.real --help 2>&1 | grep -F -- '\''-platform-public-origin'\'''; do require_marker "$release_dockerfile" "$release_proxy_marker" \ SERVER_INCREMENTAL_WEBSOCKET_PROXY_REPLACEMENT_MISSING done +for release_proxy_build_marker in \ + 'websocket_proxy_commit=${WEBSOCKET_PROXY_COMMIT:-1928f602b66443cdab40c8cdb450c811548d2749}' \ + 'websocket_proxy_archive_sha256=${WEBSOCKET_PROXY_ARCHIVE_SHA256:-4657338973f672f6ae4d6e5510d06e811b9951afea1baa27a9caa3034e487f2a}' \ + 'websocket_proxy_binary_sha256=${WEBSOCKET_PROXY_BINARY_SHA256:-9111d5a569b6326d7cd71fc3384251d9684972492a22e1e9dbbb9863019fbaaa}' \ + 'PASTURESTACK_WEBSOCKET_PROXY_BINARY_SHA256="${websocket_proxy_binary_sha256}"' \ + 'echo "${PASTURESTACK_WEBSOCKET_PROXY_BINARY_SHA256} /usr/bin/websocket-proxy.real" | sha256sum -c -'; do + require_marker "$build_script" "$release_proxy_build_marker" \ + SERVER_INCREMENTAL_WEBSOCKET_PROXY_RUNTIME_IDENTITY_MISSING +done for release_webhook_marker in \ - 'ARG WEBHOOK_AUTOMATION_SERVICE_VERSION=0.10.3' \ - 'ARG WEBHOOK_AUTOMATION_SERVICE_COMMIT=fbcc0ca07e42e9b21bda18031d0848192ec2f9a1' \ - 'ARG WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256=6babbc18cee9a192009cfadcd143e6b9a5f2b550c4dc419781f3e3657caa022a' \ - 'ARG WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256=9094f3b2527762a3e683b02d93aa00e52618e902cd409e72e34553d98d98a609' \ + 'ARG WEBHOOK_AUTOMATION_SERVICE_VERSION=0.10.4' \ + 'ARG WEBHOOK_AUTOMATION_SERVICE_COMMIT=400118b893843d2a7d7c65cc70c3449d76c4a8d8' \ + 'ARG WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256=49c4579829a04e758045fae02a5a9fca12bb0ba3af0d5e979cf9eb97f23a88a9' \ + 'ARG WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256=98c7faea665b7eb95206b8c73a6f47d644c5d2d0eae53f274f6a15faf5205744' \ 'LC_ALL=C sort "${webhook_listing}" | cmp "${webhook_expected}" -' \ 'tar --no-same-owner --no-same-permissions -xJf "${webhook_archive}"' \ - 'COPY --from=release_artifacts --chmod=0755 /out/webhook-automation-service/webhook-automation-service /usr/bin/webhook-automation-service' \ + 'install -m 0755 /out/webhook-automation-service/webhook-automation-service /out/runtime-bin/webhook-automation-service;' \ 'test "$(readlink -f /usr/bin/webhook-service)" = /usr/bin/webhook-automation-service'; do require_marker "$release_dockerfile" "$release_webhook_marker" \ SERVER_INCREMENTAL_WEBHOOK_REPLACEMENT_MISSING done for release_webhook_build_marker in \ - 'webhook_automation_service_version=${WEBHOOK_AUTOMATION_SERVICE_VERSION:-0.10.3}' \ - 'webhook_automation_service_commit=${WEBHOOK_AUTOMATION_SERVICE_COMMIT:-fbcc0ca07e42e9b21bda18031d0848192ec2f9a1}' \ - 'webhook_automation_service_archive_sha256=${WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256:-6babbc18cee9a192009cfadcd143e6b9a5f2b550c4dc419781f3e3657caa022a}' \ - 'webhook_automation_service_binary_sha256=${WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256:-9094f3b2527762a3e683b02d93aa00e52618e902cd409e72e34553d98d98a609}' \ + 'webhook_automation_service_version=${WEBHOOK_AUTOMATION_SERVICE_VERSION:-0.10.4}' \ + 'webhook_automation_service_commit=${WEBHOOK_AUTOMATION_SERVICE_COMMIT:-400118b893843d2a7d7c65cc70c3449d76c4a8d8}' \ + 'webhook_automation_service_archive_sha256=${WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256:-49c4579829a04e758045fae02a5a9fca12bb0ba3af0d5e979cf9eb97f23a88a9}' \ + 'webhook_automation_service_binary_sha256=${WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256:-98c7faea665b7eb95206b8c73a6f47d644c5d2d0eae53f274f6a15faf5205744}' \ 'WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256=${webhook_automation_service_archive_sha256}' \ - '9094f3b2527762a3e683b02d93aa00e52618e902cd409e72e34553d98d98a609 /usr/bin/webhook-automation-service' \ + '98c7faea665b7eb95206b8c73a6f47d644c5d2d0eae53f274f6a15faf5205744 /usr/bin/webhook-automation-service' \ 'PASTURESTACK_WEBHOOK_AUTOMATION_SERVICE_VERSION="${webhook_automation_service_version}"'; do require_marker "$build_script" "$release_webhook_build_marker" \ SERVER_INCREMENTAL_WEBHOOK_BUILD_GATE_MISSING done require_marker "$release_dockerfile" \ - 'ARG COMPOSE_EXECUTOR_VERSION=0.14.36' \ + 'ARG COMPOSE_EXECUTOR_VERSION=0.14.37' \ SERVER_INCREMENTAL_COMPOSE_VERSION_MISSING require_marker "$release_dockerfile" \ - 'ARG COMPOSE_EXECUTOR_BINARY_SHA256=1f542ee2dd76c7af06bc5f056c381d7e77aecaeac40f8d897df6df24a9902c0d' \ + 'ARG COMPOSE_EXECUTOR_BINARY_SHA256=a9bf9f0f77e914fe557d3e178a73c31526b0ca0adc4afbf17bf68d8d75c7ee27' \ SERVER_INCREMENTAL_COMPOSE_HASH_MISSING require_marker "$release_dockerfile" \ 'tar --no-same-owner --no-same-permissions -xzf "${web_archive}"' \ @@ -722,7 +895,7 @@ require_marker "$release_dockerfile" \ 'COPY --from=release_artifacts /out/web-console/ /tmp/pasturestack-web-console/' \ SERVER_INCREMENTAL_WEB_CONSOLE_COPY_MISSING require_marker "$release_dockerfile" \ - 'COPY --from=release_artifacts --chmod=0755 /out/compose-executor /usr/bin/compose-executor.real' \ + 'install -m 0755 /out/compose-executor /out/runtime-bin/compose-executor.real;' \ SERVER_INCREMENTAL_COMPOSE_COPY_MISSING require_marker "$release_dockerfile" \ 'COPY --from=console_broker_build --chmod=0755 /out/pasturestack-console-broker /usr/bin/pasturestack-console-broker' \ @@ -761,7 +934,7 @@ require_marker server/console-broker/broker_test.go \ 'TestStaticAssetCachePolicyIsPreserved' \ SERVER_CONSOLE_BROKER_STATIC_CACHE_POLICY_TEST_MISSING require_marker "$release_dockerfile" \ - 'test "${compose_version_output}" = "pasturestack-compose version ${COMPOSE_EXECUTOR_VERSION}"' \ + 'test "${compose_version_output}" = "compose-executor version ${COMPOSE_EXECUTOR_VERSION}"' \ SERVER_INCREMENTAL_COMPOSE_VERSION_GATE_MISSING require_marker "$release_dockerfile" \ 'footer .footer-dropdown .dropdown-menu' \ @@ -851,23 +1024,27 @@ require_marker "$build_script" \ '--file server/Dockerfile.web-compose-release' \ SERVER_INCREMENTAL_RELEASE_BUILD_PATH_MISSING require_marker "$build_script" \ - 'image=${IMAGE:-pasturestack-validation/server:v1.6.518}' \ + 'image=${IMAGE:-pasturestack-validation/server:v1.6.519}' \ SERVER_INCREMENTAL_RELEASE_BUILD_VERSION_MISSING require_marker "$build_script" \ - 'CATTLE_RANCHER_SERVER_VERSION=v1.6.518' \ + 'server_release_tag=${SERVER_RELEASE_TAG:-v1.6.519}' \ + SERVER_INCREMENTAL_RELEASE_BUILD_DEFAULT_MISSING +require_marker "$build_script" \ + 'CATTLE_RANCHER_SERVER_VERSION="${server_release_tag}"' \ SERVER_INCREMENTAL_RELEASE_BUILD_RUNTIME_VERSION_MISSING for release_engine_marker in \ - 'ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.333' \ + 'ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.334' \ 'ARG ORCHESTRATION_ENGINE_ARTIFACT=cattle.jar' \ - 'ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=8c42c0982cbc2f4569fa265ad320b341551758cb4fc0bc6d79ba06d70e20d328' \ - 'ARG ORCHESTRATION_ENGINE_COMMIT=0d94f7d879d314235e582a7f4062914a27b82709' \ + 'ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=a62d8f6ddd61c850e71bbb6fd30352b3a73c7c09e605bb5c45d549074e8824e8' \ + 'ARG ORCHESTRATION_ENGINE_COMMIT=66a87ea6901296fa11ada5df58b0618a4ca96975' \ 'COPY --from=release_artifacts /out/orchestration-engine.jar /tmp/orchestration-engine.jar' \ - "grep -Fx 'Implementation-Version: 0.183.333'" \ - 'cattle-resources-0.183.333.jar' \ - 'cattle-app-config-0.183.333.jar' \ + 'ARG ORCHESTRATION_ENGINE_VERSION=0.183.334' \ + 'grep -Fx "Implementation-Version: ${ORCHESTRATION_ENGINE_VERSION}"' \ + 'cattle-resources-${ORCHESTRATION_ENGINE_VERSION}.jar' \ + 'cattle-app-config-${ORCHESTRATION_ENGINE_VERSION}.jar' \ 'WEB-INF/lib/hazelcast-5\.7\.5\.jar' \ 'freemarker-2\.3\.35\.jar' \ - 'ENV CATTLE_CATTLE_VERSION=v0.183.333' \ + 'ENV CATTLE_CATTLE_VERSION=${ORCHESTRATION_ENGINE_RELEASE_TAG}' \ 'schema/token/token-auth.json' \ '"token.clientSessionId": "cro"' \ 'for frozen_token_schema in base superadmin token' \ @@ -882,12 +1059,12 @@ for release_engine_marker in \ SERVER_INCREMENTAL_ENGINE_REPLACEMENT_MISSING done for release_engine_build_marker in \ - 'orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.333}' \ + 'orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.334}' \ 'orchestration_engine_artifact=${ORCHESTRATION_ENGINE_ARTIFACT:-cattle.jar}' \ - 'orchestration_engine_artifact_sha256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256:-8c42c0982cbc2f4569fa265ad320b341551758cb4fc0bc6d79ba06d70e20d328}' \ - 'orchestration_engine_commit=${ORCHESTRATION_ENGINE_COMMIT:-0d94f7d879d314235e582a7f4062914a27b82709}' \ - 'CATTLE_CATTLE_VERSION=v0.183.333' \ - 'cattle-resources-0.183.333.jar' \ + 'orchestration_engine_artifact_sha256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256:-a62d8f6ddd61c850e71bbb6fd30352b3a73c7c09e605bb5c45d549074e8824e8}' \ + 'orchestration_engine_commit=${ORCHESTRATION_ENGINE_COMMIT:-66a87ea6901296fa11ada5df58b0618a4ca96975}' \ + 'CATTLE_CATTLE_VERSION="${orchestration_engine_release_tag}"' \ + 'cattle-resources-${ORCHESTRATION_ENGINE_VERSION}.jar' \ 'test "${hazelcast_entry}" = "WEB-INF/lib/hazelcast-5.7.5.jar"'; do require_marker "$build_script" "$release_engine_build_marker" \ SERVER_INCREMENTAL_ENGINE_BUILD_COORDINATE_MISSING @@ -906,26 +1083,26 @@ if grep -Eq '9ce9358d91ff002c0b64a8c1efd037b26f43ccbe11508a1446760f3baffcb564|60 fi for release_auth_marker in \ 'ARG AUTHENTICATION_SERVICE_RELEASE_BASE_URL=https://github.com/PastureStack/authentication-service/releases/download' \ - 'ARG AUTHENTICATION_SERVICE_VERSION=0.4.42' \ - 'ARG AUTHENTICATION_SERVICE_COMMIT=5589ef8fda68ae56e1afd64096965d452ee8a17e' \ - 'ARG AUTHENTICATION_SERVICE_ARCHIVE_SHA256=f14d22036a0a88d6a8d669700506bba680fc7605bbca2b337e345c5cd71500fb' \ - 'ARG AUTHENTICATION_SERVICE_BINARY_SHA256=feaabe4bba85cbe119c98a79a27abb4510401fc051f34d02aa7b48d69bdbe746' \ + 'ARG AUTHENTICATION_SERVICE_VERSION=0.4.43' \ + 'ARG AUTHENTICATION_SERVICE_COMMIT=cae736f377019bd9743648a8e9aa469a0e21b5e0' \ + 'ARG AUTHENTICATION_SERVICE_ARCHIVE_SHA256=e9218771af8dd68323c8c6fdab149c40a3ad02da9ff23f8aad6f0b2977740273' \ + 'ARG AUTHENTICATION_SERVICE_BINARY_SHA256=fe11eec4b31b43863b49a582b1dbbe309eae08fb78adc150037981174f0622da' \ 'Authentication Service archive may not contain links' \ - 'COPY --from=release_artifacts --chmod=0755 /out/authentication-service/authentication-service /usr/bin/authentication-service.real' \ + 'install -m 0755 /out/authentication-service/authentication-service /out/runtime-bin/authentication-service.real;' \ 'ENV PASTURESTACK_AUTHENTICATION_SERVICE_COMMIT=${AUTHENTICATION_SERVICE_COMMIT}' \ 'grep -aF "${marker}" /usr/bin/authentication-service.real'; do require_marker "$release_dockerfile" "$release_auth_marker" \ SERVER_INCREMENTAL_AUTHENTICATION_SERVICE_REPLACEMENT_MISSING done for release_auth_build_marker in \ - 'authentication_service_version=${AUTHENTICATION_SERVICE_VERSION:-0.4.42}' \ - 'authentication_service_commit=${AUTHENTICATION_SERVICE_COMMIT:-5589ef8fda68ae56e1afd64096965d452ee8a17e}' \ - 'authentication_service_archive_sha256=${AUTHENTICATION_SERVICE_ARCHIVE_SHA256:-f14d22036a0a88d6a8d669700506bba680fc7605bbca2b337e345c5cd71500fb}' \ - 'authentication_service_binary_sha256=${AUTHENTICATION_SERVICE_BINARY_SHA256:-feaabe4bba85cbe119c98a79a27abb4510401fc051f34d02aa7b48d69bdbe746}' \ + 'authentication_service_version=${AUTHENTICATION_SERVICE_VERSION:-0.4.43}' \ + 'authentication_service_commit=${AUTHENTICATION_SERVICE_COMMIT:-cae736f377019bd9743648a8e9aa469a0e21b5e0}' \ + 'authentication_service_archive_sha256=${AUTHENTICATION_SERVICE_ARCHIVE_SHA256:-e9218771af8dd68323c8c6fdab149c40a3ad02da9ff23f8aad6f0b2977740273}' \ + 'authentication_service_binary_sha256=${AUTHENTICATION_SERVICE_BINARY_SHA256:-fe11eec4b31b43863b49a582b1dbbe309eae08fb78adc150037981174f0622da}' \ '--build-arg "AUTHENTICATION_SERVICE_VERSION=${authentication_service_version}"' \ 'PASTURESTACK_AUTHENTICATION_SERVICE_COMMIT="${authentication_service_commit}"' \ - 'feaabe4bba85cbe119c98a79a27abb4510401fc051f34d02aa7b48d69bdbe746 /usr/bin/authentication-service.real' \ - '/usr/bin/authentication-service.real --version | grep -F "0.4.42"'; do + 'fe11eec4b31b43863b49a582b1dbbe309eae08fb78adc150037981174f0622da /usr/bin/authentication-service.real' \ + '/usr/bin/authentication-service.real --version | grep -F "0.4.43"'; do require_marker "$build_script" "$release_auth_build_marker" \ SERVER_INCREMENTAL_AUTHENTICATION_SERVICE_BUILD_GATE_MISSING done @@ -964,16 +1141,30 @@ if grep -Fq -- 'test "${new_web_root}" != "${old_web_root}"' "$release_dockerfil fi for release_vsphere_marker in \ - 'ARG VSPHERE_CLI_BUNDLE_VERSION=0.55.2' \ - 'ARG VSPHERE_CLI_BUNDLE_COMMIT=c4b27e87aa0dacce432a2c6108ee0752319e6d5b' \ - 'ARG VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256=bebcc1c0275072ac40b5bc9b80f914c40a7f0431fffebc2c06fe34a34c33a57c' \ - 'ARG GOVC_BINARY_SHA256=f8c7d82a614655c83ee119e3f170a302a9b35d9ca7efd13bbc226df2d68e5d31' \ - 'COPY --from=release_artifacts --chmod=0755 /out/vsphere-cli-bundle/govc /usr/bin/govc' \ + 'ARG VSPHERE_CLI_BUNDLE_VERSION=0.55.3' \ + 'ARG VSPHERE_CLI_BUNDLE_COMMIT=f48ab9fd9990132c85845fc162186a04f1e0418d' \ + 'ARG VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256=31be702e515741686e2c665d387562e5e993c5d2ffbdb2d614ed287243b166e4' \ + 'ARG GOVC_BINARY_SHA256=d3c4f4fab44403ec4110743b52da99f5ce3d7e3773c4661db8a87dec3ead8990' \ + 'install -m 0755 /out/vsphere-cli-bundle/govc /out/runtime-bin/govc;' \ 'ENV PASTURESTACK_VSPHERE_CLI_BUNDLE_VERSION=${VSPHERE_CLI_BUNDLE_VERSION}' \ 'ENV PASTURESTACK_GOVC_BINARY_SHA256=${GOVC_BINARY_SHA256}'; do require_marker "$release_dockerfile" "$release_vsphere_marker" \ SERVER_INCREMENTAL_VSPHERE_REPLACEMENT_MISSING done +for release_vsphere_build_marker in \ + 'vsphere_cli_bundle_version=${VSPHERE_CLI_BUNDLE_VERSION:-0.55.3}' \ + 'vsphere_cli_bundle_commit=${VSPHERE_CLI_BUNDLE_COMMIT:-f48ab9fd9990132c85845fc162186a04f1e0418d}' \ + 'vsphere_cli_bundle_archive_sha256=${VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256:-31be702e515741686e2c665d387562e5e993c5d2ffbdb2d614ed287243b166e4}' \ + 'govc_binary_sha256=${GOVC_BINARY_SHA256:-d3c4f4fab44403ec4110743b52da99f5ce3d7e3773c4661db8a87dec3ead8990}' \ + 'echo "${PASTURESTACK_GOVC_BINARY_SHA256} /usr/bin/govc" | sha256sum -c -' \ + 'test "$(/usr/bin/govc version)" = "govc ${PASTURESTACK_VSPHERE_CLI_BUNDLE_VERSION}"' \ + 'grep -Fx "Security dependency: golang.org/x/text v0.41.0"'; do + require_marker "$build_script" "$release_vsphere_build_marker" \ + SERVER_INCREMENTAL_VSPHERE_BUILD_OR_READBACK_MISSING +done +require_marker "$release_dockerfile" \ + 'Security dependency: golang.org/x/text v0.41.0' \ + SERVER_INCREMENTAL_VSPHERE_FIXED_DEPENDENCY_MISSING require_marker "$build_script" \ '[[ "$orchestration_engine_release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]' \ SERVER_INCREMENTAL_ENGINE_NUMERIC_TAG_GATE_MISSING @@ -1179,7 +1370,7 @@ require_marker "$build_script" \ 'PASTURESTACK_WEB_CONSOLE_ARTIFACT_SHA256="${web_console_artifact_sha256}"' \ SERVER_WEB_CONSOLE_RUNTIME_HASH_GATE_MISSING require_marker "$build_script" \ - 'test "$(cat "${web_root}/VERSION.txt")" = "1.6.180"' \ + 'test "$(cat "${web_root}/VERSION.txt")" = "${WEB_CONSOLE_RELEASE_TAG}"' \ SERVER_WEB_CONSOLE_RUNTIME_VERSION_GATE_MISSING require_marker "$release_dockerfile" \ "grep -aF 'pod-empty-message text-center text-muted'" \ @@ -1501,7 +1692,7 @@ for release_curl_security_marker in \ '"libssl3t64=${OPENSSL_PACKAGE_VERSION}"' \ '"openssl-provider-legacy=${OPENSSL_PACKAGE_VERSION}"' \ 'dpkg-deb --extract "${package_file}" /tmp/openssl-runtime' \ - 'sha256sum packages/*.deb tar openssl-runtime.sha256 libdbi-perl-runtime.sha256 freetype-runtime.sha256 > SHA256SUMS' \ + 'sha256sum packages/*.deb tar openssl-runtime.sha256 libdbi-perl-runtime.sha256 freetype-runtime.sha256 libpng-runtime.sha256 > SHA256SUMS' \ 'sha256sum -c /usr/share/pasturestack/security/openssl-runtime.sha256' \ 'ENV PASTURESTACK_OPENSSL_VERSION=3.5.5' \ 'ENV PASTURESTACK_OPENSSL_PACKAGE_VERSION=${OPENSSL_PACKAGE_VERSION}' \ @@ -1557,6 +1748,30 @@ for runtime_freetype_security_marker in \ require_marker "$build_script" "$runtime_freetype_security_marker" \ SERVER_FREETYPE_RUNTIME_GATE_MISSING done +for release_libpng_security_marker in \ + 'ARG LIBPNG_PACKAGE_VERSION=1.6.57-1ubuntu0.1' \ + 'ADD --checksum=sha256:f24a7f7c0428d74e33ea934696987b7336ec0962f212535185f0493792e7536e' \ + 'https://security.ubuntu.com/ubuntu/pool/main/libp/libpng1.6/libpng16-16t64_1.6.57-1ubuntu0.1_amd64.deb' \ + 'test "$(dpkg-deb -f /tmp/libpng16.deb Package)" = libpng16-16t64' \ + 'test "$(dpkg-deb -f /tmp/libpng16.deb Version)" = "${LIBPNG_PACKAGE_VERSION}"' \ + 'test "$(dpkg-deb -f /tmp/libpng16.deb Architecture)" = amd64' \ + 'dpkg-deb --extract /tmp/libpng16.deb /tmp/libpng-runtime' \ + 'sha256sum usr/lib/x86_64-linux-gnu/libpng16.so.16 > /out/libpng-runtime.sha256' \ + 'sha256sum -c /usr/share/pasturestack/security/libpng-runtime.sha256' \ + 'ldd -r /usr/lib/x86_64-linux-gnu/libpng16.so.16 2>&1' \ + 'ENV PASTURESTACK_LIBPNG_PACKAGE_VERSION=${LIBPNG_PACKAGE_VERSION}'; do + require_marker "$release_dockerfile" "$release_libpng_security_marker" \ + SERVER_LIBPNG_OFFICIAL_SECURITY_REFRESH_MISSING +done +for runtime_libpng_security_marker in \ + 'PASTURESTACK_LIBPNG_PACKAGE_VERSION=1.6.57-1ubuntu0.1' \ + 'libpng16-16t64)" = 1.6.57-1ubuntu0.1' \ + 'test "$(wc -l < /usr/share/pasturestack/security/libpng-runtime.sha256)" -eq 1' \ + 'sha256sum -c /usr/share/pasturestack/security/libpng-runtime.sha256' \ + 'ldd -r /usr/lib/x86_64-linux-gnu/libpng16.so.16 2>&1'; do + require_marker "$build_script" "$runtime_libpng_security_marker" \ + SERVER_LIBPNG_RUNTIME_GATE_MISSING +done require_marker "$build_script" \ 'PASTURESTACK_CURL_SECURITY_SNAPSHOT=20261002T000000Z' \ SERVER_CURL_IMAGE_SNAPSHOT_GATE_MISSING @@ -1647,7 +1862,7 @@ require_marker "$dockerfile" \ 'export CATTLE_AGENT_PACKAGE_PYTHON_AGENT_URL=/usr/share/cattle/artifacts/${agent_linux}' \ SERVER_HARDWARE_AGENT_EFFECTIVE_URL_MISSING require_marker server/build-api-explorer-patch-image.sh \ - 'test "$CATTLE_AGENT_PACKAGE_PYTHON_AGENT_URL" = /usr/share/cattle/artifacts/node-agent-0.13.27.tar.gz' \ + 'test "$CATTLE_AGENT_PACKAGE_PYTHON_AGENT_URL" = "/usr/share/cattle/artifacts/node-agent-${NODE_AGENT_VERSION}.tar.gz"' \ SERVER_HARDWARE_AGENT_EFFECTIVE_URL_NOT_VERIFIED require_marker "$dockerfile" \ 'ARG ZLIB_SHA256=bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16' \ @@ -1822,7 +2037,7 @@ require_marker "$dockerfile" \ 'licenses/inherited-vendor/LICENSE-async-0.9.0' \ SERVER_API_EXPLORER_PATCH_LEGAL_GATE_MISSING require_marker "$build_script" \ - 'runtime_go=1.27.0' \ + 'runtime_go=1.27.2' \ SERVER_RUNTIME_GO_GATE_MISSING require_marker "$build_script" \ 'orchestration_updated=1' \ @@ -1876,7 +2091,7 @@ done jq -e ' .["@context"] == "https://openvex.dev/ns/v0.2.0" - and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.518" + and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.519" and (.statements | length) == 51 and ([.statements[].vulnerability.name] | length == (unique | length)) and ([.statements[] | select(.status == "fixed") | .vulnerability.name] | sort) @@ -1902,7 +2117,7 @@ jq -r ' | @tsv ' "$runtime_vendor_pending" | LC_ALL=C sort -u >"$vendor_pending_fixture" bash "$vendor_pending_validator" "$runtime_vendor_pending" \ - "$vendor_pending_fixture" v1.6.518 >/dev/null + "$vendor_pending_fixture" v1.6.519 >/dev/null bash scripts/test-vendor-pending-findings.sh >/dev/null rm -f "$vendor_pending_fixture" trap - EXIT @@ -1991,4 +2206,4 @@ for release_readback_contract in \ fi done -printf 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.518 base=v1.6.460 engine=0.183.333 web_console=1.6.180 catalog_service=0.20.12 webhook_automation_service=0.10.3 authentication_service=0.4.42 curl=8.18.0-1ubuntu2.7 freemarker=2.3.35 artifact_scan=required vendor_pending=exact-set role_matrix=qa-required locale_layout=qa-required\n' +printf 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.519 base=v1.6.460 engine=0.183.334 web_console=1.6.181 catalog_service=0.20.13 webhook_automation_service=0.10.4 authentication_service=0.4.43 curl=8.18.0-1ubuntu2.7 freemarker=2.3.35 artifact_scan=required vendor_pending=exact-set role_matrix=qa-required locale_layout=qa-required\n' diff --git a/scripts/test-api-key-schema.py b/scripts/test-api-key-schema.py new file mode 100644 index 00000000000..7bdc773a294 --- /dev/null +++ b/scripts/test-api-key-schema.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Authenticated API Key schema proof on a fresh disposable loopback Server only. + +Creates one disposable local administrator. Never run on an existing database. +No password, bearer token, request body or response body is printed or saved. +""" +import argparse +import http.client +import json +import secrets + + +AUDIT_FIELDS = ('keyId', 'decision', 'outcome', 'httpStatus', 'requestId', 'actor', + 'targetType', 'targetId', 'operation', 'policyRevision', 'reason', + 'phase', 'preview', 'eventId', 'processId', 'processName', 'hostUuid', 'failureCode') + + +def _validate_schema(name, schema): + fields = schema['resourceFields'] + if name in ('apikey', 'apikeyrestricted'): + assert fields['apiKeyPolicy']['type'] == 'map[json]', name + '-policy-type' + assert fields['apiKeyPolicy']['create'] and fields['apiKeyPolicy']['update'], name + '-policy-write' + assert fields['apiKeyPolicyRevision']['update'], name + '-policy-revision-cas' + assert fields['securityConfirmation']['type'] == 'password', name + '-confirmation-secret-type' + elif name == 'apikeypolicypreview': + assert schema['collectionMethods'] == ['POST'], name + '-post-only' + assert not schema['resourceMethods'], name + '-no-resource-mutation' + for field in ('apiKeyId', 'apiKeyPolicy', 'apiKeyPolicyRevision', 'purpose', + 'requestDigest', 'confirmationRequired'): + assert field in fields, name + '-missing-' + field + for field in ('purpose', 'requestDigest', 'confirmationRequired'): + assert not fields[field].get('create') and not fields[field].get('update'), name + '-server-derived-' + field + elif name == 'auditlog': + for field in AUDIT_FIELDS: + assert field in fields, name + '-missing-' + field + assert not fields[field].get('create') and not fields[field].get('update'), name + '-readonly-' + field + else: + raise AssertionError('unexpected-schema') + + +def validate_schema(name, schema): + try: + _validate_schema(name, schema) + except (AssertionError, KeyError, TypeError) as error: + safe_name = name if name in ('apikey', 'apikeyrestricted', 'apikeypolicypreview', 'auditlog') else 'unexpected' + fields = schema.get('resourceFields', {}) if isinstance(schema, dict) else {} + if not isinstance(fields, dict): + fields = {} + metadata = {} + for field_name in ('apiKeyPolicy', 'apiKeyPolicyRevision', 'securityConfirmation') + AUDIT_FIELDS: + field = fields.get(field_name) + if not isinstance(field, dict): + metadata[field_name] = {'present': False} + continue + field_type = field.get('type') + metadata[field_name] = { + 'present': True, + 'type': field_type if field_type in ('map[json]', 'map[object]', 'json', 'object', 'password', 'string', 'int', 'long', 'boolean', 'enum', 'date') else 'nonstandard', + 'create': field.get('create') if type(field.get('create')) is bool else None, + 'update': field.get('update') if type(field.get('update')) is bool else None, + } + if isinstance(error, KeyError): + safe_code = 'MISSING_FIELD_METADATA' + elif isinstance(error, TypeError): + safe_code = 'INVALID_FIELD_METADATA' + else: + safe_code = str(error) if safe_name != 'unexpected' else 'unexpected-schema' + print('SCHEMA_CONTRACT_FAILURE ' + json.dumps({'schema': safe_name, 'code': safe_code, 'fields': metadata}, sort_keys=True), flush=True) + raise error + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--port', required=True, type=int) + parser.add_argument('--disposable', action='store_true', required=True) + args = parser.parse_args() + assert 1 <= args.port <= 65535 + token = None + + def call(method, path, body=None, anonymous=False, expected=(200, 201, 202)): + assert path.startswith(('/v1/', '/v1-auth/', '/v2-beta/')) + headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} + if token and not anonymous: + headers['Authorization'] = 'Bearer ' + token + connection = http.client.HTTPConnection('127.0.0.1', args.port, timeout=20) + try: + connection.request(method, path, body=None if body is None else json.dumps(body), headers=headers) + response = connection.getresponse() + status, content = response.status, response.read() + finally: + connection.close() + assert status in expected, method + ' ' + path + ': HTTP ' + str(status) + return json.loads(content) if content else {} + + assert call('GET', '/v1-auth/config').get('enabled') is False, 'refusing-initialized-auth' + assert not any(a.get('kind') == 'user' for a in call('GET', '/v2-beta/accounts')['data']), 'refusing-existing-users' + assert not call('GET', '/v2-beta/hosts')['data'], 'refusing-existing-hosts' + user, password = 'key-schema-smoke-' + secrets.token_hex(4), 'Qa!9-' + secrets.token_urlsafe(24) + local = {'username': user, 'password': password, 'name': 'Disposable Key Schema QA', + 'accessMode': 'unrestricted', 'enabled': False} + call('POST', '/v1/localauthconfigs', local) + token = call('POST', '/v1/token', {'code': user + ':' + password, 'authProvider': 'localAuthConfig'})['jwt'] + local['enabled'] = True + call('POST', '/v1/localauthconfigs', local) + call('GET', '/v2-beta/accounts', anonymous=True, expected=(401,)) + assert call('GET', '/v2-beta/accounts').get('type') == 'collection', 'authenticated-administrator-required' + print('PASS actual-authenticated-administrator-anonymous-denied', flush=True) + for version in ('v1', 'v2-beta'): + for name in ('apikey', 'apikeyrestricted', 'apikeypolicypreview', 'auditlog'): + validate_schema(name, call('GET', '/' + version + '/schemas/' + name)) + print('PASS authenticated-' + version + '-' + name + '-schema', flush=True) + print('API_KEY_SCHEMA_SMOKE_OK auth=local-administrator versions=v1,v2-beta schemas=8 secret_output=none', flush=True) + + +if __name__ == '__main__': + main() diff --git a/scripts/test-current-server-packaging-contract.py b/scripts/test-current-server-packaging-contract.py index d5353474698..99671579d51 100644 --- a/scripts/test-current-server-packaging-contract.py +++ b/scripts/test-current-server-packaging-contract.py @@ -17,48 +17,51 @@ PUBLISHED_508 = 'Server `v1.6.508` 已正式發布,封裝 Web Console `1.6.171`。' STALE_508_CANDIDATE = 'Server `v1.6.508` candidate packages Web Console `1.6.171`' COMPATIBILITY_CODE = 'SERVER_CREATE_RESPONSE_ORDER_COMPATIBILITY_MISSING' -WEB_SHA = 'a367bd6907281298a8e2bf0f3ad0444083db06062f3a1521db573cc5606d274e' -WEB_SOURCE = '637604b38401b19d2c9ef73d5729d356bb80c8e6' +WEB_SHA = '9199681f2c4311f3fbe8727fcf0f6fa136c9bafe9fbe9d13878c55df442a7a03' +WEB_SOURCE = '7f2bdad0c90a3241693c2ddd4475202ababba550' CATALOG_COMMIT = 'b6b658888fce50d3ec217eb4eba0f26ab0113baf' -ENGINE_SHA = '8c42c0982cbc2f4569fa265ad320b341551758cb4fc0bc6d79ba06d70e20d328' -ENGINE_SOURCE = '0d94f7d879d314235e582a7f4062914a27b82709' +ENGINE_SHA = 'a62d8f6ddd61c850e71bbb6fd30352b3a73c7c09e605bb5c45d549074e8824e8' +ENGINE_SOURCE = '66a87ea6901296fa11ada5df58b0618a4ca96975' OLD_COORDINATES = { - 'v1.6.518': 'v1.6.517', - '1.6.180': '1.6.179', - WEB_SHA: '1bb7e0acf7040738f2c6413046553609cbbaf14b833abb6ef2c7237b453eaf90', - WEB_SOURCE: '826bff55b8885efc9ff1faec0272ef442e4ff702', + 'v1.6.519': 'v1.6.518', + '1.6.181': '1.6.180', + WEB_SHA: 'a367bd6907281298a8e2bf0f3ad0444083db06062f3a1521db573cc5606d274e', + WEB_SOURCE: '637604b38401b19d2c9ef73d5729d356bb80c8e6', CATALOG_COMMIT: '7670ffd81d5f0b5570197fb03c7e55b46da45bf3', } -# Engine333 stays pinned. The stale517 Web179 values are mutation controls. -# Published component coordinates do not establish Server518 artifact/runtime PASS. +# Engine334 version stays pinned; its verified-frame WAR/source are current. +# The previous518 Web180 values remain mutation controls. +# Component coordinates do not establish Server519 artifact/runtime PASS. CATALOG_FIELDS = ( - ('VERSION', 'version', '0.20.12'), - ('COMMIT', 'commit', 'd708579092eae0fd03b2750ac594ff0396cf563b'), - ('ARCHIVE_SHA256', 'archive_sha256', '34b76c121270c603501664f7146d41916c7f45da983e326861ed4c5b9614372d'), - ('BINARY_SHA256', 'binary_sha256', '3deb43f9760d7cbb07f818dd108ab35abf9efc6908d5d7fcaf5c44a185f2fba9'), - ('SQLITE_BINARY_SHA256', 'sqlite_binary_sha256', '8805af3c0b5968a02f994d65de715c525b73637aa2dc398a0648feeeaf2fd397'), + ('VERSION', 'version', '0.20.13'), + ('COMMIT', 'commit', '4c39c73a8131ba06e9ff0aaec3b95cc27e049324'), + ('ARCHIVE_SHA256', 'archive_sha256', '29626181cb8489b016e5975ffaddc00b2a32d290b1c0066e833d27fa7a5edf83'), + ('BINARY_SHA256', 'binary_sha256', '7224c76e5643130dee047e3f1888ce6845d024307bec74d72eebdc4baeae1aea'), + ('SQLITE_BINARY_SHA256', 'sqlite_binary_sha256', '4aeda3e1ee1ca4c57f26938f3ef27651f9c8c129ee12957642960d22b95034ce'), ('LICENSE_SHA256', 'license_sha256', '0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594'), ) LEGACY_ENGINE_COORDINATES = { - '0.183.333': '0.183.332', - ENGINE_SHA: '31090699e214f8e357f7fe413ce307e722b9b53177003e5de0bbbca1bc7bc3f5', - ENGINE_SOURCE: '7a625eee58fb2bdba83d2f008bdf7dd3c0ae4295', + '0.183.334': '0.183.333', + ENGINE_SHA: '8c42c0982cbc2f4569fa265ad320b341551758cb4fc0bc6d79ba06d70e20d328', + ENGINE_SOURCE: '0d94f7d879d314235e582a7f4062914a27b82709', } EXPECTED = { COMPATIBILITY_CODE: (COMPATIBILITY, PUBLISHED_508), - 'SERVER_INCREMENTAL_RELEASE_VERSION_MISSING': (DOCKER, 'org.opencontainers.image.version="v1.6.518"'), - 'SERVER_INCREMENTAL_RELEASE_RUNTIME_VERSION_MISSING': (DOCKER, 'ENV CATTLE_RANCHER_SERVER_VERSION=v1.6.518'), - 'SERVER_INCREMENTAL_WEB_CONSOLE_VERSION_MISSING': (DOCKER, 'ARG WEB_CONSOLE_RELEASE_TAG=1.6.180'), - 'SERVER_INCREMENTAL_WEB_CONSOLE_ARTIFACT_MISSING': (DOCKER, 'ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.180.tar.gz'), + 'SERVER_INCREMENTAL_RELEASE_DEFAULT_MISSING': (DOCKER, 'ARG SERVER_RELEASE_TAG=v1.6.519'), + 'SERVER_INCREMENTAL_RELEASE_VERSION_MISSING': (DOCKER, 'org.opencontainers.image.version="${SERVER_RELEASE_TAG}"'), + 'SERVER_INCREMENTAL_RELEASE_RUNTIME_VERSION_MISSING': (DOCKER, 'ENV CATTLE_RANCHER_SERVER_VERSION=${SERVER_RELEASE_TAG}'), + 'SERVER_INCREMENTAL_WEB_CONSOLE_VERSION_MISSING': (DOCKER, 'ARG WEB_CONSOLE_RELEASE_TAG=1.6.181'), + 'SERVER_INCREMENTAL_WEB_CONSOLE_ARTIFACT_MISSING': (DOCKER, 'ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.181.tar.gz'), 'SERVER_INCREMENTAL_WEB_CONSOLE_HASH_MISSING': (DOCKER, 'ARG WEB_CONSOLE_ARTIFACT_SHA256=' + WEB_SHA), 'SERVER_INCREMENTAL_WEB_CONSOLE_COMMIT_MISSING': (DOCKER, 'ARG WEB_CONSOLE_COMMIT=' + WEB_SOURCE), 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_COMMIT_MISSING': (BUILD, 'web_console_commit=${WEB_CONSOLE_COMMIT:-' + WEB_SOURCE + '}'), - 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_VERSION_MISSING': (BUILD, 'web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.180}'), - 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_ARTIFACT_MISSING': (BUILD, 'web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.180.tar.gz}'), + 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_VERSION_MISSING': (BUILD, 'web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.181}'), + 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_ARTIFACT_MISSING': (BUILD, 'web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.181.tar.gz}'), 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_HASH_MISSING': (BUILD, 'web_console_artifact_sha256=${WEB_CONSOLE_ARTIFACT_SHA256:-' + WEB_SHA + '}'), - 'SERVER_INCREMENTAL_RELEASE_BUILD_VERSION_MISSING': (BUILD, 'image=${IMAGE:-pasturestack-validation/server:v1.6.518}'), - 'SERVER_INCREMENTAL_RELEASE_BUILD_RUNTIME_VERSION_MISSING': (BUILD, 'CATTLE_RANCHER_SERVER_VERSION=v1.6.518'), - 'SERVER_WEB_CONSOLE_RUNTIME_VERSION_GATE_MISSING': (BUILD, 'test "$(cat "${web_root}/VERSION.txt")" = "1.6.180"'), + 'SERVER_INCREMENTAL_RELEASE_BUILD_VERSION_MISSING': (BUILD, 'image=${IMAGE:-pasturestack-validation/server:v1.6.519}'), + 'SERVER_INCREMENTAL_RELEASE_BUILD_DEFAULT_MISSING': (BUILD, 'server_release_tag=${SERVER_RELEASE_TAG:-v1.6.519}'), + 'SERVER_INCREMENTAL_RELEASE_BUILD_RUNTIME_VERSION_MISSING': (BUILD, 'CATTLE_RANCHER_SERVER_VERSION="${server_release_tag}"'), + 'SERVER_WEB_CONSOLE_RUNTIME_VERSION_GATE_MISSING': (BUILD, 'test "$(cat "${web_root}/VERSION.txt")" = "${WEB_CONSOLE_RELEASE_TAG}"'), 'SERVER_VOLUME_NATIVE_READ_ONLY_BUILD_GATE_MISSING': (DOCKER, 'grep -F \'"volume.isNative" : "r"\' >/dev/null'), 'SERVER_VOLUME_NATIVE_READ_ONLY_RUNTIME_GATE_MISSING': (BUILD, r'grep -F "\"volume.isNative\" : \"r\"" >/dev/null'), 'SERVER_WEB_CREATE_IDENTITY_BUILD_GATE_MISSING': (DOCKER, "grep -aF 'createIdentity' \"${web_root}\"/assets/*.js >/dev/null"), @@ -77,26 +80,269 @@ CREATE_RESPONSE_CODES = ['SERVER_WEB_CREATE_IDENTITY_BUILD_GATE_MISSING','SERVER_WEB_CANONICAL_RECORD_BUILD_GATE_MISSING','SERVER_WEB_CREATE_IDENTITY_RUNTIME_GATE_MISSING','SERVER_WEB_CANONICAL_RECORD_RUNTIME_GATE_MISSING'] ENGINE_EXPECTED = { 'SERVER_INCREMENTAL_ENGINE_REPLACEMENT_MISSING': (DOCKER, 'release_engine_marker', ( - 'ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.333', + 'ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.334', 'ARG ORCHESTRATION_ENGINE_ARTIFACT=cattle.jar', 'ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=' + ENGINE_SHA, 'ARG ORCHESTRATION_ENGINE_COMMIT=' + ENGINE_SOURCE, - "grep -Fx 'Implementation-Version: 0.183.333'", - 'cattle-resources-0.183.333.jar', - 'cattle-app-config-0.183.333.jar', - 'ENV CATTLE_CATTLE_VERSION=v0.183.333', + 'ARG ORCHESTRATION_ENGINE_VERSION=0.183.334', + 'grep -Fx "Implementation-Version: ${ORCHESTRATION_ENGINE_VERSION}"', + 'cattle-resources-${ORCHESTRATION_ENGINE_VERSION}.jar', + 'cattle-app-config-${ORCHESTRATION_ENGINE_VERSION}.jar', + 'ENV CATTLE_CATTLE_VERSION=${ORCHESTRATION_ENGINE_RELEASE_TAG}', )), 'SERVER_INCREMENTAL_ENGINE_BUILD_COORDINATE_MISSING': (BUILD, 'release_engine_build_marker', ( - 'orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.333}', + 'orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.334}', 'orchestration_engine_artifact=${ORCHESTRATION_ENGINE_ARTIFACT:-cattle.jar}', 'orchestration_engine_artifact_sha256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256:-' + ENGINE_SHA + '}', 'orchestration_engine_commit=${ORCHESTRATION_ENGINE_COMMIT:-' + ENGINE_SOURCE + '}', - 'CATTLE_CATTLE_VERSION=v0.183.333', - 'cattle-resources-0.183.333.jar', + 'CATTLE_CATTLE_VERSION="${orchestration_engine_release_tag}"', + 'cattle-resources-${ORCHESTRATION_ENGINE_VERSION}.jar', )), } +INCREMENTAL_CONTRACTS = [ + [ + "incremental_producer_marker", + "server/Dockerfile.web-compose-release", + [ + "ARG HOST_PROVISIONER_VERSION=0.39.8", + "ARG HOST_PROVISIONER_COMMIT=385e5b536c108f17fdfcdec84c01750a5be5ab1c", + "ARG HOST_PROVISIONER_ARCHIVE_SHA256=d775f36a613b1a486a5e60d6ad61fdbd1ebb4bd22422cbb6dcb70fdd7c4abf0c", + "ARG HOST_PROVISIONER_BINARY_SHA256=1d37e20a7a1cf4f3e36036a15ff7699ef22cd8809fd14a20892034dd054fd1fc", + "ARG SECRET_DELIVERY_API_VERSION=0.3.2", + "ARG SECRET_DELIVERY_API_COMMIT=f9c3f933f14e43dc63074d45a3044b693ec1e573", + "ARG SECRET_DELIVERY_API_ARCHIVE_SHA256=ca9ab0bfddbcad84b6c1a865af8ad82fcc56cb43dcca1da240ed11a5d24cbdb2", + "ARG SECRET_DELIVERY_API_BINARY_SHA256=b5e01b7e65aeee3b456fe043142b5e70f4039ee89ffd437648368ff63e6042b6", + "ARG USAGE_TELEMETRY_AGENT_VERSION=0.4.2", + "ARG USAGE_TELEMETRY_AGENT_COMMIT=40f9af7ca932fedacdb87e30b4ef1c60a4a7444e", + "ARG USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256=5ce031c84f76b3e62dafdb04fb4ed014aa1921e83be056c2d5dd712acbce25a8", + "ARG USAGE_TELEMETRY_AGENT_BINARY_SHA256=e62a21270142181315293d7e11482288ffe8fc4d91cc8fe07dacc90b461f3709", + "ARG COMPOSE_EXECUTOR_COMMIT=88e991e823f06d2334c07d5370595aae3c48ee99", + "ARG COMPOSE_EXECUTOR_ARCHIVE_SHA256=5ff465601930218f531885a384f231d969033e4cef794ce33d16bfe24e5c1c81" + ] + ], + [ + "incremental_producer_build_marker", + "server/build-api-explorer-patch-image.sh", + [ + "host_provisioner_version=${HOST_PROVISIONER_VERSION:-0.39.8}", + "host_provisioner_commit=${HOST_PROVISIONER_COMMIT:-385e5b536c108f17fdfcdec84c01750a5be5ab1c}", + "host_provisioner_archive_sha256=${HOST_PROVISIONER_ARCHIVE_SHA256:-d775f36a613b1a486a5e60d6ad61fdbd1ebb4bd22422cbb6dcb70fdd7c4abf0c}", + "host_provisioner_binary_sha256=${HOST_PROVISIONER_BINARY_SHA256:-1d37e20a7a1cf4f3e36036a15ff7699ef22cd8809fd14a20892034dd054fd1fc}", + "secret_delivery_api_version=${SECRET_DELIVERY_API_VERSION:-0.3.2}", + "secret_delivery_api_commit=${SECRET_DELIVERY_API_COMMIT:-f9c3f933f14e43dc63074d45a3044b693ec1e573}", + "secret_delivery_api_archive_sha256=${SECRET_DELIVERY_API_ARCHIVE_SHA256:-ca9ab0bfddbcad84b6c1a865af8ad82fcc56cb43dcca1da240ed11a5d24cbdb2}", + "secret_delivery_api_binary_sha256=${SECRET_DELIVERY_API_BINARY_SHA256:-b5e01b7e65aeee3b456fe043142b5e70f4039ee89ffd437648368ff63e6042b6}", + "usage_telemetry_agent_version=${USAGE_TELEMETRY_AGENT_VERSION:-0.4.2}", + "usage_telemetry_agent_commit=${USAGE_TELEMETRY_AGENT_COMMIT:-40f9af7ca932fedacdb87e30b4ef1c60a4a7444e}", + "usage_telemetry_agent_archive_sha256=${USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256:-5ce031c84f76b3e62dafdb04fb4ed014aa1921e83be056c2d5dd712acbce25a8}", + "usage_telemetry_agent_binary_sha256=${USAGE_TELEMETRY_AGENT_BINARY_SHA256:-e62a21270142181315293d7e11482288ffe8fc4d91cc8fe07dacc90b461f3709}", + "compose_executor_version=${COMPOSE_EXECUTOR_VERSION:-0.14.37}", + "compose_executor_commit=${COMPOSE_EXECUTOR_COMMIT:-88e991e823f06d2334c07d5370595aae3c48ee99}", + "compose_executor_archive_sha256=${COMPOSE_EXECUTOR_ARCHIVE_SHA256:-5ff465601930218f531885a384f231d969033e4cef794ce33d16bfe24e5c1c81}", + "compose_executor_binary_sha256=${COMPOSE_EXECUTOR_BINARY_SHA256:-a9bf9f0f77e914fe557d3e178a73c31526b0ca0adc4afbf17bf68d8d75c7ee27}" + ] + ], + [ + "incremental_producer_runtime_marker", + "server/Dockerfile.web-compose-release", + [ + "COPY --chmod=0755 artifacts/verify-runtime-producer.sh /usr/local/bin/verify-runtime-producer", + "verify-runtime-producer \"$component\" \"$version\" \"$commit\" \"/tmp/${artifact}\" \"$binary_sha\" \"/out/$component\";", + "install -m 0755 /out/host-provisioner/host-provisioner /out/runtime-bin/host-provisioner.real;", + "cp -a /out/host-provisioner-licenses /out/runtime-licenses/host-provisioner;", + "install -m 0755 /out/secret-delivery-api/secret-delivery-api /out/runtime-bin/secret-delivery-api;", + "cp -a /out/secret-delivery-api-licenses /out/runtime-licenses/secret-delivery-api;", + "install -m 0755 /out/usage-telemetry-agent/usage-telemetry-agent /out/runtime-bin/usage-telemetry-agent;", + "cp -a /out/usage-telemetry-agent-licenses /out/runtime-licenses/usage-telemetry-agent;", + "echo \"${HOST_PROVISIONER_BINARY_SHA256} /usr/bin/host-provisioner.real\" | sha256sum -c -;", + "/usr/bin/host-provisioner.real -v | grep -F \"${HOST_PROVISIONER_VERSION}\" >/dev/null;", + "test -s /usr/share/licenses/pasturestack/host-provisioner/LICENSE;", + "test -s /usr/share/licenses/pasturestack/host-provisioner/ORIGIN.md;", + "test \"$(find /usr/share/licenses/pasturestack/host-provisioner/licenses -type f | wc -l)\" -eq 35;", + "echo \"${SECRET_DELIVERY_API_BINARY_SHA256} /usr/bin/secret-delivery-api\" | sha256sum -c -;", + "/usr/bin/secret-delivery-api --version | grep -F \"v${SECRET_DELIVERY_API_VERSION}\" >/dev/null;", + "test \"$(readlink -f /usr/bin/secrets-api)\" = /usr/bin/secret-delivery-api;", + "echo \"${USAGE_TELEMETRY_AGENT_BINARY_SHA256} /usr/bin/usage-telemetry-agent\" | sha256sum -c -;", + "/usr/bin/usage-telemetry-agent --version | grep -F \"usage-telemetry-agent ${USAGE_TELEMETRY_AGENT_VERSION} (\" >/dev/null;", + "test \"$(readlink -f /usr/bin/telemetry)\" = /usr/bin/usage-telemetry-agent;", + "test ! -e \"${license_dir}/${component}\";", + "grep -Fx 'Go compiler: 1.27.2' \"${license_dir}/SERVER-PRODUCER-SOURCES.txt\" >/dev/null;", + "test -s \"${license_dir}/${component}-${suffix}\";", + "test -s /usr/share/licenses/pasturestack/usage-telemetry-agent/usage-telemetry-agent-PRIVACY.md;" + ] + ], + [ + "incremental_go_marker", + "server/Dockerfile.web-compose-release", + [ + "ARG ARTIFACT_HELPER_IMAGE=golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61", + "test \"$(go version | awk '{print $3}')\" = go1.27.2;", + "ENV PASTURESTACK_RUNTIME_GO_VERSION=1.27.2", + "ENV PASTURESTACK_CONSOLE_BROKER_GO_VERSION=1.27.2", + "ARG ENGINE_READONLY_SCHEMA_SHA256=7f274219e8dd9c6d750a408a6edec1448b564f4b16ba2204c425c5b45cec2233", + "ARG ENGINE_RESTRICTED_SCHEMA_SHA256=f854ba99260f29e324ab7446a8592996946038920ac9bcf17689d98d1ebc5e51" + ] + ], + [ + "incremental_go_build_marker", + "server/build-api-explorer-patch-image.sh", + [ + "PASTURESTACK_RUNTIME_GO_VERSION=1.27.2", + "PASTURESTACK_CONSOLE_BROKER_GO_VERSION=1.27.2", + "grep -aF \"go1.27.2\" \"${binary}\" >/dev/null", + "runtime_go=1.27.2", + "engine_readonly_schema_sha256=${ENGINE_READONLY_SCHEMA_SHA256:-7f274219e8dd9c6d750a408a6edec1448b564f4b16ba2204c425c5b45cec2233}", + "engine_restricted_schema_sha256=${ENGINE_RESTRICTED_SCHEMA_SHA256:-f854ba99260f29e324ab7446a8592996946038920ac9bcf17689d98d1ebc5e51}" + ] + ], + [ + "incremental_input_marker", + "server/build-api-explorer-patch-image.sh", + [ + "\"$orchestration_engine_artifact|$orchestration_engine_artifact_sha256\"", + "\"$web_console_artifact|$web_console_artifact_sha256\"", + "\"websocket-proxy-${websocket_proxy_version}-linux-amd64.tar.xz|$websocket_proxy_archive_sha256\"", + "\"host-api-${host_api_version}.tar.gz|$host_api_archive_sha256\"", + "\"node-agent-${node_agent_version}.tar.gz|$node_agent_archive_sha256\"", + "\"host-provisioner-${host_provisioner_version}-linux-amd64.tar.xz|$host_provisioner_archive_sha256\"", + "\"secret-delivery-api-${secret_delivery_api_version}-linux-amd64.tar.xz|$secret_delivery_api_archive_sha256\"", + "\"usage-telemetry-agent-${usage_telemetry_agent_version}-linux-amd64.tar.xz|$usage_telemetry_agent_archive_sha256\"", + "\"catalog-service-${catalog_service_version}.tar.xz|$catalog_service_archive_sha256\"", + "\"catalog-service-${catalog_service_version}-LICENSE.txt|$catalog_service_license_sha256\"", + "\"authentication-service-${authentication_service_version}-linux-amd64.tar.xz|$authentication_service_archive_sha256\"", + "\"webhook-automation-service-${webhook_automation_service_version}-linux-amd64.tar.xz|$webhook_automation_service_archive_sha256\"", + "\"compose-executor-${compose_executor_version}-linux-amd64.gz|$compose_executor_archive_sha256\"", + "\"vsphere-cli-bundle-${vsphere_cli_bundle_version}-linux-amd64.tar.xz|$vsphere_cli_bundle_archive_sha256\"", + "[[ \"$actual_components\" == \"$expected_components\" ]]", + "printf '%s %s\\n' \"$component_sha256\" \"$component_directory/$component_name\" | sha256sum -c -" + ] + ] +] +EXACT_COMPONENT_INPUTS = [ + "\"$orchestration_engine_artifact|$orchestration_engine_artifact_sha256\"", + "\"$web_console_artifact|$web_console_artifact_sha256\"", + "\"websocket-proxy-${websocket_proxy_version}-linux-amd64.tar.xz|$websocket_proxy_archive_sha256\"", + "\"host-api-${host_api_version}.tar.gz|$host_api_archive_sha256\"", + "\"node-agent-${node_agent_version}.tar.gz|$node_agent_archive_sha256\"", + "\"host-provisioner-${host_provisioner_version}-linux-amd64.tar.xz|$host_provisioner_archive_sha256\"", + "\"secret-delivery-api-${secret_delivery_api_version}-linux-amd64.tar.xz|$secret_delivery_api_archive_sha256\"", + "\"usage-telemetry-agent-${usage_telemetry_agent_version}-linux-amd64.tar.xz|$usage_telemetry_agent_archive_sha256\"", + "\"catalog-service-${catalog_service_version}.tar.xz|$catalog_service_archive_sha256\"", + "\"catalog-service-${catalog_service_version}-LICENSE.txt|$catalog_service_license_sha256\"", + "\"authentication-service-${authentication_service_version}-linux-amd64.tar.xz|$authentication_service_archive_sha256\"", + "\"webhook-automation-service-${webhook_automation_service_version}-linux-amd64.tar.xz|$webhook_automation_service_archive_sha256\"", + "\"compose-executor-${compose_executor_version}-linux-amd64.gz|$compose_executor_archive_sha256\"", + "\"vsphere-cli-bundle-${vsphere_cli_bundle_version}-linux-amd64.tar.xz|$vsphere_cli_bundle_archive_sha256\"" +] + + +RUNTIME_BINARIES = [ + [ + "/out/catalog-service/catalog-service", + "/out/runtime-bin/catalog-service.real" + ], + [ + "/out/catalog-service/catalog-service-sqlite", + "/out/runtime-bin/catalog-service-sqlite" + ], + [ + "/out/authentication-service/authentication-service", + "/out/runtime-bin/authentication-service.real" + ], + [ + "/out/websocket-proxy/websocket-proxy", + "/out/runtime-bin/websocket-proxy.real" + ], + [ + "/out/webhook-automation-service/webhook-automation-service", + "/out/runtime-bin/webhook-automation-service" + ], + [ + "/out/compose-executor", + "/out/runtime-bin/compose-executor.real" + ], + [ + "/out/vsphere-cli-bundle/govc", + "/out/runtime-bin/govc" + ], + [ + "/out/host-provisioner/host-provisioner", + "/out/runtime-bin/host-provisioner.real" + ], + [ + "/out/secret-delivery-api/secret-delivery-api", + "/out/runtime-bin/secret-delivery-api" + ], + [ + "/out/usage-telemetry-agent/usage-telemetry-agent", + "/out/runtime-bin/usage-telemetry-agent" + ] +] +RUNTIME_LEGAL = [ + [ + "/out/catalog-service-licenses", + "/out/runtime-licenses/catalog-service" + ], + [ + "/out/host-provisioner-licenses", + "/out/runtime-licenses/host-provisioner" + ], + [ + "/out/secret-delivery-api-licenses", + "/out/runtime-licenses/secret-delivery-api" + ], + [ + "/out/usage-telemetry-agent-licenses", + "/out/runtime-licenses/usage-telemetry-agent" + ], + [ + "/out/webhook-automation-service/webhook-automation-service-COMPATIBILITY.md", + "/out/webhook-automation-service/webhook-automation-service-LICENSES.txt", + "/out/webhook-automation-service/webhook-automation-service-SOURCES.txt", + "/out/webhook-automation-service/webhook-automation-service-THIRD-PARTY-NOTICES.md", + "/out/runtime-licenses/webhook-automation-service/" + ], + [ + "/out/vsphere-cli-bundle/vsphere-cli-bundle-LICENSES.txt", + "/out/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt", + "/out/vsphere-cli-bundle/vsphere-cli-bundle-THIRD-PARTY-NOTICES.txt", + "/out/runtime-licenses/vsphere-cli-bundle/" + ] +] +RUNTIME_LAYOUT_MARKERS = [ + "install -m 0755 /out/catalog-service/catalog-service /out/runtime-bin/catalog-service.real;", + "install -m 0755 /out/catalog-service/catalog-service-sqlite /out/runtime-bin/catalog-service-sqlite;", + "install -m 0755 /out/authentication-service/authentication-service /out/runtime-bin/authentication-service.real;", + "install -m 0755 /out/websocket-proxy/websocket-proxy /out/runtime-bin/websocket-proxy.real;", + "install -m 0755 /out/webhook-automation-service/webhook-automation-service /out/runtime-bin/webhook-automation-service;", + "install -m 0755 /out/compose-executor /out/runtime-bin/compose-executor.real;", + "install -m 0755 /out/vsphere-cli-bundle/govc /out/runtime-bin/govc;", + "install -m 0755 /out/host-provisioner/host-provisioner /out/runtime-bin/host-provisioner.real;", + "install -m 0755 /out/secret-delivery-api/secret-delivery-api /out/runtime-bin/secret-delivery-api;", + "install -m 0755 /out/usage-telemetry-agent/usage-telemetry-agent /out/runtime-bin/usage-telemetry-agent;", + "cp -a /out/catalog-service-licenses /out/runtime-licenses/catalog-service;", + "cp -a /out/host-provisioner-licenses /out/runtime-licenses/host-provisioner;", + "cp -a /out/secret-delivery-api-licenses /out/runtime-licenses/secret-delivery-api;", + "cp -a /out/usage-telemetry-agent-licenses /out/runtime-licenses/usage-telemetry-agent;", + "/out/webhook-automation-service/webhook-automation-service-COMPATIBILITY.md", + "/out/webhook-automation-service/webhook-automation-service-LICENSES.txt", + "/out/webhook-automation-service/webhook-automation-service-SOURCES.txt", + "/out/webhook-automation-service/webhook-automation-service-THIRD-PARTY-NOTICES.md", + "/out/runtime-licenses/webhook-automation-service/", + "/out/vsphere-cli-bundle/vsphere-cli-bundle-LICENSES.txt", + "/out/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt", + "/out/vsphere-cli-bundle/vsphere-cli-bundle-THIRD-PARTY-NOTICES.txt", + "/out/runtime-licenses/vsphere-cli-bundle/", + "COPY --from=release_artifacts --chmod=0755 /out/runtime-bin/ /usr/bin/", + "COPY --from=release_artifacts /out/runtime-licenses/ /usr/share/licenses/pasturestack/", + "test \"$(find /out/runtime-bin -mindepth 1 -maxdepth 1 -type f | wc -l)\" -eq 10;", + "test \"$(find /out/runtime-licenses -mindepth 1 -maxdepth 1 -type d | wc -l)\" -eq 6;", + "test -z \"$(find /out/runtime-bin /out/runtime-licenses -type l -print -quit)\"" +] + + def stale(marker): for coordinate, old in OLD_COORDINATES.items(): marker = marker.replace(coordinate, old) @@ -121,6 +367,58 @@ def engine_gate_markers(gate, variable): return markers +def verify_runtime_layout(files, gate=GATE): + declared = engine_gate_markers(gate, 'incremental_layout_marker') + if declared != RUNTIME_LAYOUT_MARKERS: + raise AssertionError('CURRENT_RUNTIME_LAYOUT_GATE_MISMATCH') + source = files[DOCKER].replace('\\\n', ' ') + # Interpret executable statements; comments, wrong modes, destinations, + # extra binaries and changed legal-source sets are not equivalent COPYs. + installs = re.findall(r'(?:^|[;\n])\s*install -m 0755 (\S+) (\S+)(?=\s*;)', source) + installs = [list(row) for row in installs if row[1].startswith('/out/runtime-bin/')] + if installs != RUNTIME_BINARIES: + raise AssertionError('CURRENT_RUNTIME_BINARY_LAYOUT_MISMATCH') + copies = re.findall(r'(?:^|[;\n])\s*cp -a\s+([^;\n]+)(?=\s*;)', source) + legal = [row.split() for row in copies if row.split()[-1].startswith('/out/runtime-licenses/')] + if legal != RUNTIME_LEGAL: + raise AssertionError('CURRENT_RUNTIME_LEGAL_LAYOUT_MISMATCH') + for marker in RUNTIME_LAYOUT_MARKERS: + if marker not in files[DOCKER]: + raise AssertionError('CURRENT_RUNTIME_LAYOUT_SOURCE_MISMATCH') + if marker.startswith('test '): + command = marker.rstrip(';') + if len(re.findall(r'(?:^|[;\n])\s*' + re.escape(command) + r'(?=\s*(?:;|\n|$))', source)) != 1: + raise AssertionError('CURRENT_RUNTIME_LAYOUT_GUARD_BYPASS') + expected = [ + 'COPY --from=release_artifacts --chmod=0755 /out/runtime-bin/ /usr/bin/', + 'COPY --from=release_artifacts /out/runtime-licenses/ /usr/share/licenses/pasturestack/', + ] + actual = [line for line in files[DOCKER].splitlines() + if line.startswith('COPY ') and re.search(r'/out/runtime-(?:bin|licenses)/', line)] + if actual != expected or len(re.findall(r'^FROM ', files[DOCKER], re.M)) != 5: + raise AssertionError('CURRENT_RUNTIME_AGGREGATE_COPY_MISMATCH') + + +def verify_incremental_contract(files, gate=GATE): + for variable, name, markers in INCREMENTAL_CONTRACTS: + declared = engine_gate_markers(gate, variable) + if declared != markers: + raise AssertionError('CURRENT_INCREMENTAL_GATE_CONTRACT_MISMATCH') + for marker in markers: + if marker not in files[name]: + raise AssertionError('CURRENT_INCREMENTAL_SOURCE_CONTRACT_MISMATCH') + # Commands, unlike ARG/COPY/ENV text, must remain executable and + # fail closed. A comment or "|| true" is not an equivalent guard. + if marker.endswith(';'): + source = files[name].replace('\\\n', ' ') + command = marker[:-1] + pattern = (r'(?:^|[;\n])\s*(?:(?:do|then)\s+)?' + re.escape(command) + + r'(?=\s*(?:;|\n|$))') + if len(re.findall(pattern, source)) != 1: + raise AssertionError('CURRENT_INCREMENTAL_GUARD_BYPASS') + verify_runtime_layout(files, gate) + + def verify(files, gate=GATE): # Extract the actual single-quoted require_marker calls. Membership has the # same fixed-string semantics as the unchanged gate's grep -Fq -- invocation. @@ -172,16 +470,17 @@ def verify(files, gate=GATE): pipeline = r'(?:^|[;\n])\s*' + re.escape(marker) + r'(?=\s*(?:;|\n|$))' if len(re.findall(pipeline, source)) != 1: raise AssertionError('CURRENT_CATALOG_HASH_BYPASS') - if 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.518"' not in gate: + if 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.519"' not in gate: raise AssertionError('CURRENT_VEX_GATE_PIN_MISMATCH') - if '"$vendor_pending_fixture" v1.6.518 >/dev/null' not in gate: + if '"$vendor_pending_fixture" v1.6.519 >/dev/null' not in gate: raise AssertionError('CURRENT_VENDOR_GATE_PIN_MISMATCH') - if json.loads(files[VEX]).get('@id') != 'https://github.com/PastureStack/server/security/openvex/v1.6.518': + if json.loads(files[VEX]).get('@id') != 'https://github.com/PastureStack/server/security/openvex/v1.6.519': raise AssertionError('CURRENT_VEX_RELEASE_MISMATCH') - if json.loads(files[VENDOR]).get('release') != 'v1.6.518': + if json.loads(files[VENDOR]).get('release') != 'v1.6.519': raise AssertionError('CURRENT_VENDOR_RELEASE_MISMATCH') - if 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.518 base=v1.6.460 engine=0.183.333 web_console=1.6.180 catalog_service=0.20.12 ' not in gate: + if 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.519 base=v1.6.460 engine=0.183.334 web_console=1.6.181 catalog_service=0.20.13 ' not in gate: raise AssertionError('CURRENT_SUMMARY_MISMATCH') + verify_incremental_contract(files, gate) def previous_gate(gate=GATE): @@ -193,22 +492,247 @@ def previous_gate(gate=GATE): if stale(marker) != marker: replacements["'" + marker + "' "] = "'" + stale(marker) + "' " replacements.update({ - 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.518"': - 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.517"', - '"$vendor_pending_fixture" v1.6.518 >/dev/null': - '"$vendor_pending_fixture" v1.6.517 >/dev/null', - 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.518 base=v1.6.460 engine=0.183.333 web_console=1.6.180 catalog_service=0.20.12 ': - 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.517 base=v1.6.460 engine=0.183.333 web_console=1.6.179 catalog_service=0.20.12 ', + 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.519"': + 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.518"', + '"$vendor_pending_fixture" v1.6.519 >/dev/null': + '"$vendor_pending_fixture" v1.6.518 >/dev/null', + 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.519 base=v1.6.460 engine=0.183.334 web_console=1.6.181 catalog_service=0.20.13 ': + 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.518 base=v1.6.460 engine=0.183.334 web_console=1.6.180 catalog_service=0.20.12 ', }) for marker, stale_marker in replacements.items(): if gate.count(marker) != 1: raise AssertionError('UNEXPECTED_CURRENT_MARKER_SHAPE') gate = gate.replace(marker, stale_marker) - # Synthetic mutation leaves current engine pins; separate tests reject332. + # Synthetic mutation leaves current engine pins; separate tests reject333. return gate class Tests(unittest.TestCase): + def test_runtime_aggregate_keeps_exact_ten_binaries_six_legal_dirs_and_two_copies(self): + verify_runtime_layout(FILES) + self.assertEqual(len(RUNTIME_BINARIES), 10) + self.assertEqual(len(RUNTIME_LEGAL), 6) + for command in (['install -m 0755 ' + ' '.join(row) + ';' for row in RUNTIME_BINARIES] + + ['cp -a ' + ' '.join(row) + ';' for row in RUNTIME_LEGAL]): + normalized = FILES[DOCKER].replace('\\\n', ' ') + pattern = r'\s+'.join(re.escape(part) for part in command.split()) + found = re.search(pattern, normalized) + self.assertIsNotNone(found) + for replacement in ('# ' + found.group(), found.group().replace('/out/', '/wrong/', 1), + found.group()[:-1] + ' || true;'): + with self.subTest(command=command, replacement=replacement): + mutated = normalized[:found.start()] + replacement + normalized[found.end():] + with self.assertRaisesRegex(AssertionError, 'CURRENT_RUNTIME_.*(?:MISMATCH|BYPASS)'): + verify_runtime_layout(dict(FILES, **{DOCKER: mutated})) + for marker in RUNTIME_LAYOUT_MARKERS: + with self.subTest(gate=marker): + with self.assertRaisesRegex(AssertionError, 'CURRENT_RUNTIME_LAYOUT_GATE_MISMATCH'): + verify_runtime_layout(FILES, GATE.replace(marker, 'REMOVED_LAYOUT_GATE')) + for marker in ( + 'COPY --from=release_artifacts --chmod=0755 /out/runtime-bin/ /usr/bin/', + 'COPY --from=release_artifacts /out/runtime-licenses/ /usr/share/licenses/pasturestack/', + 'test "$(find /out/runtime-bin -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 10;', + 'test "$(find /out/runtime-licenses -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 6;', + 'test -z "$(find /out/runtime-bin /out/runtime-licenses -type l -print -quit)"', + ): + for replacement in ('# ' + marker, marker.replace('0755', '0644').replace('-eq 10', '-eq 11').replace('-eq 6', '-eq 7'), + marker.rstrip(';') + ' || true;'): + if replacement == marker: + continue + with self.subTest(marker=marker, bypass=replacement): + with self.assertRaisesRegex(AssertionError, 'CURRENT_RUNTIME_.*(?:MISMATCH|BYPASS)'): + verify_runtime_layout(dict(FILES, **{DOCKER: FILES[DOCKER].replace(marker, replacement)})) + + def test_other_formal_component_coordinates_and_stale_inputs_fail_closed(self): + coordinates = [ + [ + "AUTHENTICATION_SERVICE", + "authentication_service", + "0.4.43", + "cae736f377019bd9743648a8e9aa469a0e21b5e0", + "e9218771af8dd68323c8c6fdab149c40a3ad02da9ff23f8aad6f0b2977740273", + "fe11eec4b31b43863b49a582b1dbbe309eae08fb78adc150037981174f0622da" + ], + [ + "WEBSOCKET_PROXY", + "websocket_proxy", + "0.23.15", + "1928f602b66443cdab40c8cdb450c811548d2749", + "4657338973f672f6ae4d6e5510d06e811b9951afea1baa27a9caa3034e487f2a", + "9111d5a569b6326d7cd71fc3384251d9684972492a22e1e9dbbb9863019fbaaa" + ], + [ + "WEBHOOK_AUTOMATION_SERVICE", + "webhook_automation_service", + "0.10.4", + "400118b893843d2a7d7c65cc70c3449d76c4a8d8", + "49c4579829a04e758045fae02a5a9fca12bb0ba3af0d5e979cf9eb97f23a88a9", + "98c7faea665b7eb95206b8c73a6f47d644c5d2d0eae53f274f6a15faf5205744" + ], + [ + "COMPOSE_EXECUTOR", + "compose_executor", + "0.14.37", + "88e991e823f06d2334c07d5370595aae3c48ee99", + "5ff465601930218f531885a384f231d969033e4cef794ce33d16bfe24e5c1c81", + "a9bf9f0f77e914fe557d3e178a73c31526b0ca0adc4afbf17bf68d8d75c7ee27" + ], + [ + "VSPHERE_CLI_BUNDLE", + "vsphere_cli_bundle", + "0.55.3", + "f48ab9fd9990132c85845fc162186a04f1e0418d", + "31be702e515741686e2c665d387562e5e993c5d2ffbdb2d614ed287243b166e4", + None + ] +] + + def check(files, gate=GATE): + for upper, lower, version, commit, archive, binary in coordinates: + values = dict(VERSION=version, COMMIT=commit, ARCHIVE_SHA256=archive) + if binary is not None: + values['BINARY_SHA256'] = binary + for field, value in values.items(): + docker_marker = 'ARG ' + upper + '_' + field + '=' + value + build_marker = lower + '_' + field.lower() + '=$' + '{' + upper + '_' + field + ':-' + value + '}' + self.assertIn(docker_marker, files[DOCKER]) + self.assertIn(build_marker, files[BUILD]) + self.assertIn(docker_marker, gate) + # Proxy version is unchanged and is already bound by its + # Docker/runtime version gate, not a duplicated build marker. + if not (upper == 'WEBSOCKET_PROXY' and field == 'VERSION'): + self.assertIn(build_marker, gate) + self.assertIn('ARG GOVC_BINARY_SHA256=d3c4f4fab44403ec4110743b52da99f5ce3d7e3773c4661db8a87dec3ead8990', files[DOCKER]) + + check(FILES) + for upper, lower, version, commit, archive, binary in coordinates: + values = dict(VERSION=version, COMMIT=commit, ARCHIVE_SHA256=archive) + if binary is not None: + values['BINARY_SHA256'] = binary + for field, value in values.items(): + markers = ( + (DOCKER, 'ARG ' + upper + '_' + field + '=' + value), + (BUILD, lower + '_' + field.lower() + '=$' + '{' + upper + '_' + field + ':-' + value + '}'), + ) + for name, marker in markers: + with self.subTest(component=upper, field=field, file=name): + with self.assertRaises(AssertionError): + check(dict(FILES, **{name: FILES[name].replace(marker, marker.replace(value, '0' * len(value)))})) + if marker in GATE: + with self.assertRaises(AssertionError): + check(FILES, GATE.replace(marker, 'REMOVED_FORMAL_PIN')) + + def test_active_incremental_go1272_does_not_rewrite_historical_base_go1270(self): + verify_incremental_contract(FILES) + historical = ( + 'ARG GO_BUILDER_IMAGE=golang:1.27.0-bookworm@sha256:ded31c68586d2e49e760acc2e65a884b23d032e9bbbed0ae0c55abd3fcaf4452', + 'ENV PASTURESTACK_RUNTIME_GO_VERSION=1.27.0', + ) + base_patch = (REPO / 'server/Dockerfile.api-explorer-patch').read_text(encoding='utf-8') + normalized = GATE.replace('\\\n', ' ') + for marker in historical: + self.assertIn(marker, base_patch) + self.assertRegex(normalized, r'require_marker "\$dockerfile"\s+' + re.escape(shlex.quote(marker))) + for name in (DOCKER, BUILD): + with self.subTest(active=name): + with self.assertRaisesRegex(AssertionError, 'CURRENT_INCREMENTAL_SOURCE_CONTRACT_MISMATCH'): + verify_incremental_contract(dict(FILES, **{name: FILES[name].replace('1.27.2', '1.27.0')})) + + def test_incremental_formal_producer_pins_and_every_missing_gate_rejected(self): + verify_incremental_contract(FILES) + for variable, name, markers in INCREMENTAL_CONTRACTS: + for marker in markers: + with self.subTest(name=name, missing=marker): + mutated = dict(FILES, **{name: FILES[name].replace(marker, 'REMOVED_INCREMENTAL_CONTRACT')}) + with self.assertRaisesRegex(AssertionError, 'CURRENT_INCREMENTAL_SOURCE_CONTRACT_MISMATCH'): + verify_incremental_contract(mutated) + with self.subTest(variable=variable, gate=marker): + shell_marker = shlex.quote(marker) + self.assertIn(shell_marker, GATE) + mutated_gate = GATE.replace(shell_marker, shlex.quote('REMOVED_GATE_CONTRACT')) + self.assertNotEqual(mutated_gate, GATE) + with self.assertRaisesRegex(AssertionError, 'CURRENT_INCREMENTAL_GATE_CONTRACT_MISMATCH'): + verify_incremental_contract(FILES, mutated_gate) + producer_markers = INCREMENTAL_CONTRACTS[0][2] + for marker in producer_markers: + expected_count = 1 if marker.startswith('ARG COMPOSE_EXECUTOR_COMMIT=') else 2 + self.assertEqual(FILES[DOCKER].splitlines().count(marker), expected_count) + field, value = marker.split('=', 1) + for invalid in ('PENDING_OFFICIAL_SOURCE', '0' * len(value)): + with self.subTest(field=field, invalid=invalid): + with self.assertRaisesRegex(AssertionError, 'CURRENT_INCREMENTAL_SOURCE_CONTRACT_MISMATCH'): + verify_incremental_contract(dict(FILES, **{DOCKER: FILES[DOCKER].replace(marker, field + '=' + invalid)})) + + def test_new_producer_binary_alias_version_and_legal_commands_fail_closed(self): + verify_incremental_contract(FILES) + commands = INCREMENTAL_CONTRACTS[2][2] + for marker in commands: + if not marker.endswith(';'): + continue + for replacement in ('# ' + marker, marker[:-1] + ' || true;', marker[:-1] + ' || :;'): + with self.subTest(marker=marker, bypass=replacement): + with self.assertRaisesRegex(AssertionError, 'CURRENT_(?:INCREMENTAL|RUNTIME)_.*(?:MISMATCH|BYPASS)'): + verify_incremental_contract(dict(FILES, **{DOCKER: FILES[DOCKER].replace(marker, replacement)})) + for upper, version, digest, binary in ( + ('HOST_PROVISIONER', '0.39.8', '1d37e20a7a1cf4f3e36036a15ff7699ef22cd8809fd14a20892034dd054fd1fc', '/usr/bin/host-provisioner.real'), + ('SECRET_DELIVERY_API', '0.3.2', 'b5e01b7e65aeee3b456fe043142b5e70f4039ee89ffd437648368ff63e6042b6', '/usr/bin/secret-delivery-api'), + ('USAGE_TELEMETRY_AGENT', '0.4.2', 'e62a21270142181315293d7e11482288ffe8fc4d91cc8fe07dacc90b461f3709', '/usr/bin/usage-telemetry-agent'), + ): + self.assertIn(digest + ' ' + binary, FILES[BUILD]) + self.assertIn('PASTURESTACK_' + upper + '_VERSION="' + '$' + '{' + upper.lower() + '_version}"', FILES[BUILD]) + + def test_compose_executor_artifact_and_real_binary_keep_distinct_version_contracts(self): + source = FILES[DOCKER] + artifact = 'compose_version_output="$(/out/compose-executor --version 2>&1)"; \\\n test "${compose_version_output}" = "compose-executor version ${COMPOSE_EXECUTOR_VERSION}";' + runtime = 'compose_version_output="$(/usr/bin/compose-executor.real --version 2>&1)"; \\\n test "${compose_version_output}" = "pasturestack-compose version ${COMPOSE_EXECUTOR_VERSION}";' + # The producer dispatches by executable basename. Only the unrenamed + # artifact uses the executor name; the installed .real binary is the CLI. + self.assertEqual(source.count(artifact), 1) + self.assertEqual(source.count(runtime), 1) + self.assertIn(shlex.quote('test "${compose_version_output}" = "compose-executor version ${COMPOSE_EXECUTOR_VERSION}"'), GATE) + self.assertIn('"pasturestack-compose version ${PASTURESTACK_COMPOSE_EXECUTOR_VERSION}"', FILES[BUILD]) + + def test_original_fourteen_component_inputs_keep_exact_sha_fetch_and_five_stages(self): + verify_incremental_contract(FILES) + block = FILES[BUILD].split(' component_assets=(\n', 1)[1].split(' )', 1)[0] + self.assertEqual([line.strip() for line in block.splitlines()], EXACT_COMPONENT_INPUTS) + self.assertEqual(len(EXACT_COMPONENT_INPUTS), 14) + self.assertEqual(len(re.findall(r'^FROM ', FILES[DOCKER], re.M)), 5) + self.assertIn('FROM scratch AS component_input', FILES[DOCKER]) + self.assertEqual(len(re.findall(r'^\s+fetch-server-component ', FILES[DOCKER], re.M)), 12) + self.assertIn('"${release_url%/}/v${version}/${artifact}" "${artifact}" "${archive_sha}"', FILES[DOCKER]) + release_stage = FILES[DOCKER].split('FROM ${ARTIFACT_HELPER_IMAGE} AS release_artifacts', 1)[1].split('FROM ${UBUNTU_SECURITY_IMAGE}', 1)[0] + self.assertNotIn('curl -fsSL', release_stage) + self.assertIn('"catalog-service-${CATALOG_SERVICE_VERSION}-LICENSE.txt" "${CATALOG_SERVICE_LICENSE_SHA256}"', release_stage) + fetch = (REPO / 'server/artifacts/fetch-server-component.sh').read_text(encoding='utf-8') + hash_guard = 'printf \'%s %s\\n\' "$expected" "$output" | sha256sum -c -' + self.assertEqual(fetch.count(hash_guard), 1) + self.assertIn('test -f "$input" && test ! -L "$input"', fetch) + self.assertIn('https://*) ;;', fetch) + self.assertIn('*) echo \'Unknown component artifact mode\' >&2; exit 2 ;;', fetch) + for replacement in ('# ' + hash_guard, hash_guard + ' || true', hash_guard + ' || :'): + source = fetch.replace(hash_guard, replacement) + pattern = r'(?m)^' + re.escape(hash_guard) + r'$' + self.assertEqual(len(re.findall(pattern, source)), 0) + + def test_producer_helper_keeps_original_package_metadata_and_legal_boundaries(self): + source = (REPO / 'server/artifacts/verify-runtime-producer.sh').read_text(encoding='utf-8') + for marker in ( + 'echo "$binary_sha $output/$type" | sha256sum -c -', + 'test "$(printf \'%s\\n\' "$metadata" | awk \'NR==1 {print $2}\')" = go1.27.2', + "'CGO_ENABLED=0' 'GOOS=linux' 'GOARCH=amd64' 'GOAMD64=v1'", + 'test ! -s "$duplicates"', + "! grep -Eq '^[lh]' \"$verbose\"", + 'tar --no-same-owner --no-same-permissions --strip-components=1 -xJf "$archive"', + '[[ "$output" == "/out/$type" && ! -e "$output" ]]', + 'grep -Fx "Release source commit: $commit"', + 'test "$(find "$output/licenses" -type f | wc -l)" -eq 35', + '"$type-PRIVACY.md"', + 'SERVER-PRODUCER-SOURCES.txt', + 'if [[ "${entry##*/}" != "$type" ]]', + ): + self.assertIn(marker, source) + def test_catalog13_runtime_defaults_and_operator_override_boundary(self): verify(FILES) catalog = {'catalogs': {'pasturestack': { @@ -235,7 +759,8 @@ def test_freetype_official_deb_and_library_checks_fail_closed(self): def check(files): self.assertEqual(files[DOCKER].splitlines().count('ARG FREETYPE_PACKAGE_VERSION=' + version), 2) self.assertEqual(len(re.findall(archive, files[DOCKER], re.M)), 1) - self.assertEqual(len(re.findall(r'^FROM ', files[DOCKER], re.M)), 4) + self.assertEqual(len(re.findall(r'^FROM ', files[DOCKER], re.M)), 5) + self.assertIn('FROM scratch AS component_input', files[DOCKER]) for name, variable in paths: for marker in engine_gate_markers(GATE, variable): self.assertIn(marker, files[name]) @@ -295,7 +820,8 @@ def test_candidate_coordinates_and_pending_rejection_use_actual_pin_predicates(s marker = "printf '%s\\n' \"${" + variable + "}\" | grep -Eq '^[0-9a-f]{" + str(digits) + "}$'; \\\n" self.assertEqual(FILES[DOCKER].count(marker), 1) artifact_guard = "printf '%s\\n' \"${WEB_CONSOLE_ARTIFACT_SHA256}\" | grep -Eq '^[0-9a-f]{64}$';" - self.assertLess(FILES[DOCKER].index(artifact_guard), FILES[DOCKER].index('curl -fsSL --retry 5')) + self.assertLess(FILES[DOCKER].index(artifact_guard), FILES[DOCKER].index('fetch-server-component "${COMPONENT_ARTIFACT_MODE}"')) + self.assertIn('"${WEB_CONSOLE_ARTIFACT}" "${WEB_CONSOLE_ARTIFACT_SHA256}" "${web_archive}";', FILES[DOCKER]) def test_catalog_both_stages_build_defaults_and_pending_reject(self): verify(FILES) @@ -324,7 +850,8 @@ def test_catalog_both_stages_build_defaults_and_pending_reject(self): self.assertLess(FILES[BUILD].index(actual_guard), FILES[BUILD].index('docker buildx build')) self.assertIsNotNone(re.fullmatch(pattern, value)) self.assertIsNone(re.fullmatch(pattern, 'PENDING_OFFICIAL_SOURCE')) - self.assertEqual(len(re.findall(r'^FROM ', FILES[DOCKER], re.M)), 4) + self.assertEqual(len(re.findall(r'^FROM ', FILES[DOCKER], re.M)), 5) + self.assertIn('FROM scratch AS component_input', FILES[DOCKER]) self.assertIn('SERVER_COMPONENT_RELEASE_COORDINATES_PENDING', GATE) self.assertIn('PENDING_OFFICIAL_(SOURCE|ARCHIVE|BINARY|SQLITE_BINARY)', GATE) @@ -469,6 +996,50 @@ def test_previous_pin_fixture_keeps_versioned_historical_markers(self): self.assertIn(marker, previous_gate()) self.assertNotRegex(GATE, r"require_marker README\.md '## v[0-9]") + def test_proxy_binary_readback_uses_the_verified_component_parameter(self): + for field, value in ( + ('COMMIT', '1928f602b66443cdab40c8cdb450c811548d2749'), + ('ARCHIVE_SHA256', '4657338973f672f6ae4d6e5510d06e811b9951afea1baa27a9caa3034e487f2a'), + ('BINARY_SHA256', '9111d5a569b6326d7cd71fc3384251d9684972492a22e1e9dbbb9863019fbaaa'), + ): + docker_marker = 'ARG WEBSOCKET_PROXY_' + field + '=' + value + build_marker = 'websocket_proxy_' + field.lower() + '=${WEBSOCKET_PROXY_' + field + ':-' + value + '}' + self.assertEqual(2, FILES[DOCKER].count(docker_marker)) + self.assertIn(build_marker, FILES[BUILD]) + self.assertIn(docker_marker, GATE) + self.assertIn(build_marker, GATE) + markers = ( + 'PASTURESTACK_WEBSOCKET_PROXY_BINARY_SHA256="${websocket_proxy_binary_sha256}"', + 'echo "${PASTURESTACK_WEBSOCKET_PROXY_BINARY_SHA256} /usr/bin/websocket-proxy.real" | sha256sum -c -', + ) + for marker in markers: + self.assertIn(marker, FILES[BUILD]) + self.assertIn(marker, GATE) + self.assertNotRegex(FILES[BUILD], r'(?m)^[0-9a-f]{64} /usr/bin/websocket-proxy[.]real$') + + def test_govc_fixed_dependency_and_binary_readback_use_formal_pins(self): + for upper, lower, value in ( + ('VSPHERE_CLI_BUNDLE_VERSION', 'vsphere_cli_bundle_version', '0.55.3'), + ('VSPHERE_CLI_BUNDLE_COMMIT', 'vsphere_cli_bundle_commit', 'f48ab9fd9990132c85845fc162186a04f1e0418d'), + ('VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256', 'vsphere_cli_bundle_archive_sha256', '31be702e515741686e2c665d387562e5e993c5d2ffbdb2d614ed287243b166e4'), + ('GOVC_BINARY_SHA256', 'govc_binary_sha256', 'd3c4f4fab44403ec4110743b52da99f5ce3d7e3773c4661db8a87dec3ead8990'), + ): + docker_marker = 'ARG ' + upper + '=' + value + build_marker = lower + '=${' + upper + ':-' + value + '}' + self.assertEqual(1 if upper.endswith('_COMMIT') else 2, FILES[DOCKER].count(docker_marker)) + self.assertIn(build_marker, FILES[BUILD]) + self.assertIn(docker_marker, GATE) + self.assertIn(build_marker, GATE) + for marker in ( + 'echo "${PASTURESTACK_GOVC_BINARY_SHA256} /usr/bin/govc" | sha256sum -c -', + 'test "$(/usr/bin/govc version)" = "govc ${PASTURESTACK_VSPHERE_CLI_BUNDLE_VERSION}"', + 'Security dependency: golang.org/x/text v0.41.0', + ): + self.assertIn(marker, FILES[BUILD]) + self.assertIn(marker, GATE) + self.assertNotRegex(FILES[BUILD], r'(?m)^[0-9a-f]{64} /usr/bin/govc$') + self.assertIn('Dependency-only override: govc/go.mod and govc/go.sum; upstream Go source unchanged', FILES[DOCKER]) + def test_current_readme_keeps_install_and_upgrade_contract(self): block = GATE.split('for current_readme_marker in ', 1)[1].split('; do', 1)[0] markers = shlex.split(block.replace('\\\n', ' ')) diff --git a/scripts/test-server-component-candidate.py b/scripts/test-server-component-candidate.py new file mode 100644 index 00000000000..85fe0527fdc --- /dev/null +++ b/scripts/test-server-component-candidate.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Recipe/asset boundary tests. Fixtures are not release component identities.""" +import hashlib +import contextlib +import importlib.util +import io +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import tarfile +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +ENTRY = ROOT / "server/build-component-candidate.sh" +BUILD = ROOT / "server/build-api-explorer-patch-image.sh" +VERIFY = ROOT / "server/artifacts/verify-host-api-package.sh" +COMMITS = ("ORCHESTRATION_ENGINE_COMMIT", "WEB_CONSOLE_COMMIT", "WEBSOCKET_PROXY_COMMIT", "HOST_API_COMMIT", "NODE_AGENT_COMMIT") +HASHES = ("ORCHESTRATION_ENGINE_ARTIFACT_SHA256", "ENGINE_READONLY_SCHEMA_SHA256", "ENGINE_RESTRICTED_SCHEMA_SHA256", + "WEB_CONSOLE_ARTIFACT_SHA256", "WEBSOCKET_PROXY_ARCHIVE_SHA256", "WEBSOCKET_PROXY_BINARY_SHA256", + "HOST_API_ARCHIVE_SHA256", "HOST_API_BINARY_SHA256", "HOST_API_APPLY_SHA256", + "NODE_AGENT_ARCHIVE_SHA256", "NODE_AGENT_BINARY_SHA256", "NODE_AGENT_APPLY_SHA256", + "HOST_PROVISIONER_ARCHIVE_SHA256", "SECRET_DELIVERY_API_ARCHIVE_SHA256", "USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256", + "CATALOG_SERVICE_ARCHIVE_SHA256", "CATALOG_SERVICE_LICENSE_SHA256", "AUTHENTICATION_SERVICE_ARCHIVE_SHA256", + "WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256", "COMPOSE_EXECUTOR_ARCHIVE_SHA256", "VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256") +LOCAL_ASSETS = (("cattle.jar", "ORCHESTRATION_ENGINE_ARTIFACT_SHA256"), + ("web-console-1.6.181.tar.gz", "WEB_CONSOLE_ARTIFACT_SHA256"), + ("websocket-proxy-0.23.15-linux-amd64.tar.xz", "WEBSOCKET_PROXY_ARCHIVE_SHA256"), + ("host-api-0.38.5.tar.gz", "HOST_API_ARCHIVE_SHA256"), + ("node-agent-0.13.28.tar.gz", "NODE_AGENT_ARCHIVE_SHA256"), + ("host-provisioner-0.39.8-linux-amd64.tar.xz", "HOST_PROVISIONER_ARCHIVE_SHA256"), + ("secret-delivery-api-0.3.2-linux-amd64.tar.xz", "SECRET_DELIVERY_API_ARCHIVE_SHA256"), + ("usage-telemetry-agent-0.4.2-linux-amd64.tar.xz", "USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256"), + ("catalog-service-0.20.13.tar.xz", "CATALOG_SERVICE_ARCHIVE_SHA256"), + ("catalog-service-0.20.13-LICENSE.txt", "CATALOG_SERVICE_LICENSE_SHA256"), + ("authentication-service-0.4.43-linux-amd64.tar.xz", "AUTHENTICATION_SERVICE_ARCHIVE_SHA256"), + ("webhook-automation-service-0.10.4-linux-amd64.tar.xz", "WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256"), + ("compose-executor-0.14.37-linux-amd64.gz", "COMPOSE_EXECUTOR_ARCHIVE_SHA256"), + ("vsphere-cli-bundle-0.55.3-linux-amd64.tar.xz", "VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256")) + + +def fixture_environment(): + environment = {"PATH": os.environ["PATH"], "SOURCE_DATE_EPOCH": "1"} + for name in COMMITS: + environment[name] = hashlib.sha1(("unit-fixture-" + name).encode()).hexdigest() + for name in HASHES: + environment[name] = hashlib.sha256(("unit-fixture-" + name).encode()).hexdigest() + return environment + + +class ComponentRecipeTest(unittest.TestCase): + def run_entry(self, environment, *arguments): + return subprocess.run(["bash", str(ENTRY), *arguments], env=environment, text=True, capture_output=True) + + def test_missing_or_invalid_identity_fails_before_any_build(self): + environment = fixture_environment() + del environment["HOST_API_ARCHIVE_SHA256"] + result = self.run_entry(environment, "--check-inputs") + self.assertEqual(2, result.returncode) + self.assertIn("Missing required component identity: HOST_API_ARCHIVE_SHA256", result.stderr) + environment = fixture_environment() + environment["WEB_CONSOLE_COMMIT"] = "unverified" + self.assertNotEqual(0, self.run_entry(environment, "--check-inputs").returncode) + + def test_input_check_uses_new_coordinates_and_new_package_id_without_build(self): + environment = fixture_environment() + result = self.run_entry(environment, "--check-inputs") + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("server=v1.6.519 engine=v0.183.334 web=1.6.181 proxy=0.23.15 host=0.38.5 node=0.13.28", result.stdout) + self.assertIn(environment["HOST_API_COMMIT"][:32], result.stdout) + environment["HOST_API_PACKAGE_ID"] = "301025de31c07073cc03efa17e1ab8ef" + self.assertNotEqual(0, self.run_entry(environment, "--check-inputs").returncode) + + def local_fixture(self, path): + environment = fixture_environment() + assets = path / "assets" + assets.mkdir() + for name, variable in LOCAL_ASSETS: + content = ("unit-fixture:" + name).encode() + (assets / name).write_bytes(content) + environment[variable] = hashlib.sha256(content).hexdigest() + commands = path / "commands" + commands.mkdir() + git = commands / "git" + git.write_text("#!/bin/sh\nexit 0\n") + git.chmod(0o755) + docker = commands / "docker" + docker.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >"$FIXTURE_TRACE"\nexit 88\n') + docker.chmod(0o755) + environment.update(PATH=str(commands) + os.pathsep + environment["PATH"], + PASTURESTACK_COMPONENT_ARTIFACT_DIR=str(assets), + PASTURESTACK_SERVER_REVISION=hashlib.sha1(b"unit-fixture-server").hexdigest(), + FIXTURE_TRACE=str(path / "docker-arguments")) + return environment, assets + + def test_fourteen_local_assets_are_hashed_and_named_context_reaches_formal_recipe(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) + environment, assets = self.local_fixture(path) + self.assertEqual(14, len(LOCAL_ASSETS)) + self.assertEqual({name for name, _ in LOCAL_ASSETS}, {asset.name for asset in assets.iterdir()}) + result = self.run_entry(environment) + self.assertEqual(88, result.returncode, result.stderr) + arguments = (path / "docker-arguments").read_text() + self.assertIn("component_input=" + str(assets), arguments) + self.assertIn("COMPONENT_ARTIFACT_MODE=local", arguments) + self.assertIn("HOST_API_PACKAGE_MODE=producer", arguments) + self.assertIn("SERVER_RELEASE_TAG=v1.6.519", arguments) + self.assertIn("server/Dockerfile.web-compose-release", arguments) + for variable in HASHES: + self.assertIn(environment[variable], arguments) + for variable in COMMITS: + self.assertIn(environment[variable], arguments) + + def test_ancillary_url_defaults_keep_https_and_sha_pins_with_explicit_loopback_only(self): + defaults = { + 'COMPOSE_EXECUTOR_RELEASE_BASE_URL': 'https://github.com/PastureStack/compose-cli/releases/download', + 'VSPHERE_CLI_BUNDLE_RELEASE_BASE_URL': 'https://github.com/PastureStack/vsphere-cli-bundle/releases/download', + } + dockerfile = (ROOT / 'server/Dockerfile.web-compose-release').read_text() + for variable, url in defaults.items(): + self.assertIn('ARG ' + variable + '=' + url, dockerfile) + self.assertNotIn('ENV ' + variable, dockerfile) + self.assertIn('echo "${COMPOSE_EXECUTOR_ARCHIVE_SHA256} ${compose_archive}" | sha256sum -c -', dockerfile) + self.assertIn('echo "${VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256} ${vsphere_archive}" | sha256sum -c -', dockerfile) + self.assertIn('test "${PASTURESTACK_ALLOW_LOOPBACK_ARTIFACTS}" = 1', dockerfile) + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) + environment, _ = self.local_fixture(path) + result = self.run_entry(environment) + self.assertEqual(88, result.returncode, result.stderr) + arguments = (path / 'docker-arguments').read_text() + for variable, url in defaults.items(): + self.assertIn(variable + '=' + url, arguments) + for variable in defaults: + for url, allowed, reaches_docker in ( + ('http://127.0.0.1:18795/cache', False, False), + ('http://127.0.0.1:18795/cache', True, True), + ('http://10.0.0.144:18795/cache', True, False)): + with self.subTest(variable=variable, url=url, allowed=allowed): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) + environment, _ = self.local_fixture(path) + environment[variable] = url + if allowed: + environment['PASTURESTACK_ALLOW_LOOPBACK_ARTIFACTS'] = '1' + result = self.run_entry(environment) + self.assertEqual(reaches_docker, (path / 'docker-arguments').exists()) + if reaches_docker: + self.assertEqual(88, result.returncode, result.stderr) + self.assertIn(variable + '=' + url, (path / 'docker-arguments').read_text()) + else: + self.assertNotEqual(0, result.returncode) + + def test_missing_file_or_wrong_hash_cannot_reach_build(self): + for name, variable in LOCAL_ASSETS: + with self.subTest(asset=name), tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) + environment, assets = self.local_fixture(path) + asset = assets / name + content = asset.read_bytes() + asset.unlink() + self.assertNotEqual(0, self.run_entry(environment).returncode) + self.assertFalse((path / "docker-arguments").exists(), "a missing input must fail before Docker") + asset.write_bytes(content) + environment[variable] = "0" * 64 + self.assertNotEqual(0, self.run_entry(environment).returncode) + self.assertFalse((path / "docker-arguments").exists(), "a wrong SHA must fail before Docker") + + def test_extra_file_or_corrupted_asset_cannot_reach_build(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) + environment, assets = self.local_fixture(path) + (assets / "unexpected").write_text("not a component") + self.assertNotEqual(0, self.run_entry(environment).returncode) + self.assertFalse((path / "docker-arguments").exists()) + (assets / "unexpected").unlink() + (assets / "cattle.jar").write_bytes(b"changed after hash") + self.assertNotEqual(0, self.run_entry(environment).returncode) + self.assertFalse((path / "docker-arguments").exists()) + + def test_local_symlink_is_rejected_without_build(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) + environment, assets = self.local_fixture(path) + original = assets / "cattle.jar" + original.rename(path / "outside.jar") + original.symlink_to(path / "outside.jar") + self.assertNotEqual(0, self.run_entry(environment).returncode) + self.assertFalse((path / "docker-arguments").exists()) + + def producer_archive(self, directory, corrupt=False, component="host-api"): + package_id = hashlib.sha1(b"unit-fixture-host-package").hexdigest()[:32] + content = {package_id + "/bin/" + component: b"unit fixture binary", package_id + "/apply.sh": b"unit fixture apply", + package_id + "/licenses/LICENSE test.txt": b"unit fixture license"} + binary_hash = hashlib.sha256(content[package_id + "/bin/" + component]).hexdigest() + apply_hash = hashlib.sha256(content[package_id + "/apply.sh"]).hexdigest() + for algorithm, manifest, checksum in ((hashlib.sha1, "SHA1SUMS", "SHA1SUMSSUM"), + (hashlib.sha256, "SHA256SUMS", "SHA256SUMSSUM")): + values = sorted((name, value) for name, value in content.items() if "/SHA" not in name) + body = "".join(algorithm(value).hexdigest() + " *" + name + "\n" for name, value in values).encode() + content[package_id + "/" + manifest] = body + content[package_id + "/" + checksum] = (algorithm(body).hexdigest() + " " + package_id + "/" + manifest + "\n").encode() + if corrupt: + content[package_id + "/bin/" + component] = b"modified without updating manifests" + archive = directory / (component + ".tar.gz") + with tarfile.open(archive, "w:gz") as output: + for name, value in content.items(): + member = tarfile.TarInfo(name); member.size = len(value) + output.addfile(member, io.BytesIO(value)) + return archive, package_id, binary_hash, apply_hash + + def test_producer_checksums_and_license_files_are_accepted_without_legacy_repair(self): + with tempfile.TemporaryDirectory() as temporary: + values = self.producer_archive(Path(temporary)) + result = subprocess.run(["bash", str(VERIFY), *map(str, values)], text=True, capture_output=True) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("HOST_API_PRODUCER_PACKAGE_OK", result.stdout) + + def test_producer_corruption_and_wrong_root_are_rejected(self): + with tempfile.TemporaryDirectory() as temporary: + values = self.producer_archive(Path(temporary), corrupt=True) + self.assertNotEqual(0, subprocess.run(["bash", str(VERIFY), *map(str, values)], capture_output=True).returncode) + wrong_root = (values[0], "301025de31c07073cc03efa17e1ab8ef", *values[2:]) + self.assertNotEqual(0, subprocess.run(["bash", str(VERIFY), *map(str, wrong_root)], capture_output=True).returncode) + + def test_node_producer_uses_same_manifest_verifier_and_only_known_component_names(self): + verifier = ROOT / "server/artifacts/verify-agent-package.sh" + with tempfile.TemporaryDirectory() as temporary: + values = self.producer_archive(Path(temporary), component="node-agent") + result = subprocess.run(["bash", str(verifier), "node-agent", *map(str, values)], text=True, capture_output=True) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("AGENT_PRODUCER_PACKAGE_OK component=node-agent", result.stdout) + self.assertNotEqual(0, subprocess.run(["bash", str(verifier), "unknown", *map(str, values)], capture_output=True).returncode) + + def test_agent_archive_verbose_failure_cannot_bypass_link_rejection(self): + # Deterministically reproduce an early grep match followed by tar's + # SIGPIPE status, without timing or constructing a malicious payload. + verifier = ROOT / "server/artifacts/verify-agent-package.sh" + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + values = self.producer_archive(directory, component="node-agent") + commands = directory / "commands" + commands.mkdir() + real_tar = shutil.which("tar") + self.assertIsNotNone(real_tar) + wrapper = commands / "tar" + wrapper.write_text("#!/usr/bin/env bash\n" + "if [[ ${1:-} == -tvzf ]]; then\n" + " printf '%s\\n' 'lrwxrwxrwx link -> outside'\n" + " exit 141\n" + "fi\nexec " + shlex.quote(real_tar) + " \"$@\"\n", + encoding="utf-8") + wrapper.chmod(0o755) + environment = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ["PATH"]) + result = subprocess.run(["bash", str(verifier), "node-agent", *map(str, values)], + env=environment, text=True, capture_output=True) + self.assertNotEqual(0, result.returncode) + self.assertNotIn("AGENT_PRODUCER_PACKAGE_OK", result.stdout) + + def test_security_pins_and_overlay_path_are_retained(self): + text = (ROOT / "server/Dockerfile.web-compose-release").read_text() + for marker in ("ghcr.io/pasturestack/server:v1.6.460@sha256:c855af8aea232dacc5bb6df68e2271d482c68b53c43ab0c108ec19118f5ab403", + "OPENSSL_PACKAGE_VERSION=3.5.5-1ubuntu3.7", "CURL_PACKAGE_VERSION=8.18.0-1ubuntu2.7", + "FREETYPE_PACKAGE_VERSION=2.14.2+dfsg-1ubuntu0.2", "UBUNTU_RUNTIME_SECURITY_SNAPSHOT=20261002T000000Z", + "PatchV1GlobalSubscribe verify-hardware", "schema/service/service-auth.json", "db/core-124.xml"): + self.assertIn(marker, text) + + def test_formal_workflow_reads_recipe_defaults_and_does_not_pin_old_host_assets(self): + workflow = (ROOT / ".github/workflows/publish-current-server.yml").read_text() + self.assertIn("s/^ARG SERVER_RELEASE_TAG=//p", workflow) + self.assertIn("s/^ARG HOST_API_VERSION=//p", workflow) + self.assertNotIn("host-api-0.38.4.tar.gz", workflow) + self.assertIn("verify-host-api-package.sh", workflow) + # Parse each YAML literal run block without executing any workflow, + # release, registry, Docker or shell command from it. + lines = workflow.splitlines() + blocks = [] + for index, line in enumerate(lines): + if not re.match(r"^\s+run: \|$", line): + continue + indentation = len(line) - len(line.lstrip()) + block = [] + for following in lines[index + 1:]: + if following.strip() and len(following) - len(following.lstrip()) <= indentation: + break + block.append(following[indentation + 2:] if following.strip() else "") + blocks.append("\n".join(block) + "\n") + self.assertGreater(len(blocks), 5) + for block in blocks: + result = subprocess.run(["bash", "-n"], input=block, text=True, capture_output=True) + self.assertEqual(0, result.returncode, result.stderr) + + def test_schema_proof_rejects_writable_audit_and_client_derived_preview_fields(self): + specification = importlib.util.spec_from_file_location("key_schema_proof", ROOT / "scripts/test-api-key-schema.py") + proof = importlib.util.module_from_spec(specification) + specification.loader.exec_module(proof) + audit = {'resourceFields': {field: {'create': False, 'update': False} for field in proof.AUDIT_FIELDS}} + proof.validate_schema('auditlog', audit) + audit['resourceFields']['keyId']['update'] = True + with self.assertRaises(AssertionError): + proof.validate_schema('auditlog', audit) + preview = {'collectionMethods': ['POST'], 'resourceMethods': [], 'resourceFields': { + field: {} for field in ('apiKeyId', 'apiKeyPolicy', 'apiKeyPolicyRevision', + 'purpose', 'requestDigest', 'confirmationRequired')}} + proof.validate_schema('apikeypolicypreview', preview) + preview['resourceFields']['purpose']['create'] = True + with self.assertRaises(AssertionError): + proof.validate_schema('apikeypolicypreview', preview) + + def test_schema_failure_diagnostics_keep_only_safe_metadata(self): + specification = importlib.util.spec_from_file_location('key_schema_failure_proof', ROOT / 'scripts/test-api-key-schema.py') + proof = importlib.util.module_from_spec(specification) + specification.loader.exec_module(proof) + secret = 'fixture-secret-that-must-not-be-printed' + valid = {'resourceFields': { + 'apiKeyPolicy': {'type': 'map[json]', 'create': True, 'update': True}, + 'apiKeyPolicyRevision': {'update': True}, + 'securityConfirmation': {'type': 'password'}, + }, 'token': secret} + output = io.StringIO() + with contextlib.redirect_stdout(output): + proof.validate_schema('apikey', valid) + self.assertEqual('', output.getvalue()) + cases = ( + ({'resourceFields': {'apiKeyPolicy': {'type': secret}}, 'token': secret}, AssertionError, 'apikey-policy-type'), + ({'resourceFields': {}, 'token': secret}, KeyError, 'MISSING_FIELD_METADATA'), + ({'resourceFields': None, 'token': secret}, TypeError, 'INVALID_FIELD_METADATA'), + ) + for schema, exception, code in cases: + output = io.StringIO() + with contextlib.redirect_stdout(output), self.assertRaises(exception): + proof.validate_schema('apikey', schema) + self.assertIn(code, output.getvalue()) + self.assertNotIn(secret, output.getvalue()) + self.assertNotIn('"token"', output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/Dockerfile.web-compose-release b/server/Dockerfile.web-compose-release index 580ff41b27b..3774e1486dd 100644 --- a/server/Dockerfile.web-compose-release +++ b/server/Dockerfile.web-compose-release @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.7 -ARG ARTIFACT_HELPER_IMAGE=golang:1.27.0-bookworm@sha256:ded31c68586d2e49e760acc2e65a884b23d032e9bbbed0ae0c55abd3fcaf4452 +ARG ARTIFACT_HELPER_IMAGE=golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 ARG UBUNTU_SECURITY_IMAGE=ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b ARG BASE_IMAGE=ghcr.io/pasturestack/server:v1.6.460@sha256:c855af8aea232dacc5bb6df68e2271d482c68b53c43ab0c108ec19118f5ab403 @@ -8,7 +8,7 @@ FROM ${ARTIFACT_HELPER_IMAGE} AS console_broker_build WORKDIR /src COPY console-broker/go.mod console-broker/go.sum ./ RUN set -eux; \ - test "$(go version | awk '{print $3}')" = go1.27.0; \ + test "$(go version | awk '{print $3}')" = go1.27.2; \ go mod download; \ go mod verify COPY console-broker/*.go ./ @@ -23,46 +23,97 @@ RUN set -eux; \ -ldflags='-s -w -buildid=' \ -o /out/pasturestack-console-broker . +FROM scratch AS component_input +COPY artifacts/component-input.txt /remote-input.txt + FROM ${ARTIFACT_HELPER_IMAGE} AS release_artifacts ARG SOURCE_DATE_EPOCH COPY --chmod=0755 artifacts/repair-host-api-sha256.sh /usr/local/bin/repair-host-api-sha256 +COPY --chmod=0755 artifacts/fetch-server-component.sh /usr/local/bin/fetch-server-component +COPY --chmod=0755 artifacts/verify-runtime-producer.sh /usr/local/bin/verify-runtime-producer +COPY --chmod=0755 artifacts/verify-host-api-package.sh /usr/local/bin/verify-host-api-package +COPY --chmod=0755 artifacts/verify-agent-package.sh /usr/local/bin/verify-agent-package.sh +COPY --from=component_input / /tmp/pasturestack-component-input/ +ARG COMPONENT_ARTIFACT_MODE=remote +ARG PASTURESTACK_ALLOW_LOOPBACK_ARTIFACTS=0 +ARG HOST_API_VERSION=0.38.5 +ARG HOST_API_PACKAGE_MODE=producer +ARG HOST_API_RELEASE_BASE_URL=https://github.com/PastureStack/host-api/releases/download +ARG HOST_API_RELEASE_TAG=v0.38.5 +ARG HOST_API_ARCHIVE_SHA256=ba111038695aa03a82c51d60944bbbdbbec30bb269051ba29fa21d46ada17179 +ARG HOST_API_PACKAGE_ID=84754fe8bc79027d0c72f492923c4786 +ARG HOST_API_BINARY_SHA256=fda5080d6cfec0a4c1e9daf15b43cea2dce7ffb116c8292ff0f3547c8fb371f7 +ARG HOST_API_APPLY_SHA256=8a21f63099832afb571755011bbcf8d97710994a50c419b20700cbc31efced0f +ARG HOST_API_COMMIT=84754fe8bc79027d0c72f492923c47861aa74b99 +ARG NODE_AGENT_VERSION=0.13.28 +ARG NODE_AGENT_RELEASE_BASE_URL=https://github.com/PastureStack/node-agent/releases/download +ARG NODE_AGENT_COMMIT=bb4e057c089e5384bc9836b42db206326c690387 +ARG NODE_AGENT_PACKAGE_ID=bb4e057c089e5384bc9836b42db20632 +ARG NODE_AGENT_ARCHIVE_SHA256=a16ae6be5fb72fa2ba38bd1076fd3b9e0536c48e70995ffa49d17b8063e2f8d0 +ARG NODE_AGENT_BINARY_SHA256=ce8342e10da07c50cc414b471cadc67db4dd390e6e2dcb632b30c542f658b021 +ARG NODE_AGENT_APPLY_SHA256=dd8cb342518a43e7468cc121db5c4e44b16731f2620b77ea0fd03c02c03766a9 ARG WEB_CONSOLE_RELEASE_BASE_URL=https://github.com/PastureStack/web-console/releases/download -ARG WEB_CONSOLE_RELEASE_TAG=1.6.180 -ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.180.tar.gz -ARG WEB_CONSOLE_ARTIFACT_SHA256=a367bd6907281298a8e2bf0f3ad0444083db06062f3a1521db573cc5606d274e +ARG WEB_CONSOLE_RELEASE_TAG=1.6.181 +ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.181.tar.gz +ARG WEB_CONSOLE_ARTIFACT_SHA256=9199681f2c4311f3fbe8727fcf0f6fa136c9bafe9fbe9d13878c55df442a7a03 ARG CATALOG_SERVICE_RELEASE_BASE_URL=https://github.com/PastureStack/catalog-service/releases/download -ARG CATALOG_SERVICE_VERSION=0.20.12 -ARG CATALOG_SERVICE_COMMIT=d708579092eae0fd03b2750ac594ff0396cf563b -ARG CATALOG_SERVICE_ARCHIVE_SHA256=34b76c121270c603501664f7146d41916c7f45da983e326861ed4c5b9614372d -ARG CATALOG_SERVICE_BINARY_SHA256=3deb43f9760d7cbb07f818dd108ab35abf9efc6908d5d7fcaf5c44a185f2fba9 -ARG CATALOG_SERVICE_SQLITE_BINARY_SHA256=8805af3c0b5968a02f994d65de715c525b73637aa2dc398a0648feeeaf2fd397 +ARG CATALOG_SERVICE_VERSION=0.20.13 +ARG CATALOG_SERVICE_COMMIT=4c39c73a8131ba06e9ff0aaec3b95cc27e049324 +ARG CATALOG_SERVICE_ARCHIVE_SHA256=29626181cb8489b016e5975ffaddc00b2a32d290b1c0066e833d27fa7a5edf83 +ARG CATALOG_SERVICE_BINARY_SHA256=7224c76e5643130dee047e3f1888ce6845d024307bec74d72eebdc4baeae1aea +ARG CATALOG_SERVICE_SQLITE_BINARY_SHA256=4aeda3e1ee1ca4c57f26938f3ef27651f9c8c129ee12957642960d22b95034ce ARG CATALOG_SERVICE_LICENSE_SHA256=0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594 ARG AUTHENTICATION_SERVICE_RELEASE_BASE_URL=https://github.com/PastureStack/authentication-service/releases/download -ARG AUTHENTICATION_SERVICE_VERSION=0.4.42 -ARG AUTHENTICATION_SERVICE_ARCHIVE_SHA256=f14d22036a0a88d6a8d669700506bba680fc7605bbca2b337e345c5cd71500fb -ARG AUTHENTICATION_SERVICE_BINARY_SHA256=feaabe4bba85cbe119c98a79a27abb4510401fc051f34d02aa7b48d69bdbe746 +ARG AUTHENTICATION_SERVICE_VERSION=0.4.43 +ARG AUTHENTICATION_SERVICE_ARCHIVE_SHA256=e9218771af8dd68323c8c6fdab149c40a3ad02da9ff23f8aad6f0b2977740273 +ARG AUTHENTICATION_SERVICE_BINARY_SHA256=fe11eec4b31b43863b49a582b1dbbe309eae08fb78adc150037981174f0622da ARG WEBSOCKET_PROXY_RELEASE_BASE_URL=https://github.com/PastureStack/websocket-proxy/releases/download -ARG WEBSOCKET_PROXY_VERSION=0.23.14 -ARG WEBSOCKET_PROXY_ARCHIVE_SHA256=c55108c3dbfd8e6579fc768a1988920db83c6f605ca1284b60edf92ae8d0160e -ARG WEBSOCKET_PROXY_BINARY_SHA256=efd0c78779a620b4b0f74a10eb3f3edd8886e8d23f22dc4624d8e9971085a26d +ARG WEBSOCKET_PROXY_VERSION=0.23.15 +ARG WEBSOCKET_PROXY_ARCHIVE_SHA256=4657338973f672f6ae4d6e5510d06e811b9951afea1baa27a9caa3034e487f2a +ARG WEBSOCKET_PROXY_BINARY_SHA256=9111d5a569b6326d7cd71fc3384251d9684972492a22e1e9dbbb9863019fbaaa +ARG WEBSOCKET_PROXY_COMMIT=1928f602b66443cdab40c8cdb450c811548d2749 ARG WEBHOOK_AUTOMATION_SERVICE_RELEASE_BASE_URL=https://github.com/PastureStack/webhook-automation-service/releases/download -ARG WEBHOOK_AUTOMATION_SERVICE_VERSION=0.10.3 -ARG WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256=6babbc18cee9a192009cfadcd143e6b9a5f2b550c4dc419781f3e3657caa022a -ARG WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256=9094f3b2527762a3e683b02d93aa00e52618e902cd409e72e34553d98d98a609 +ARG WEBHOOK_AUTOMATION_SERVICE_VERSION=0.10.4 +ARG WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256=49c4579829a04e758045fae02a5a9fca12bb0ba3af0d5e979cf9eb97f23a88a9 +ARG WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256=98c7faea665b7eb95206b8c73a6f47d644c5d2d0eae53f274f6a15faf5205744 ARG ORCHESTRATION_ENGINE_RELEASE_BASE_URL=https://github.com/PastureStack/orchestration-engine/releases/download -ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.333 +ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.335 ARG ORCHESTRATION_ENGINE_ARTIFACT=cattle.jar -ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=8c42c0982cbc2f4569fa265ad320b341551758cb4fc0bc6d79ba06d70e20d328 -ARG COMPOSE_EXECUTOR_VERSION=0.14.36 -ARG COMPOSE_EXECUTOR_ARCHIVE_SHA256=47e2ba1686c1b136c7edcac530495c3e29c351e947f0b4d08ebe68d33f98cf66 -ARG COMPOSE_EXECUTOR_BINARY_SHA256=1f542ee2dd76c7af06bc5f056c381d7e77aecaeac40f8d897df6df24a9902c0d -ARG VSPHERE_CLI_BUNDLE_VERSION=0.55.2 -ARG VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256=bebcc1c0275072ac40b5bc9b80f914c40a7f0431fffebc2c06fe34a34c33a57c -ARG GOVC_BINARY_SHA256=f8c7d82a614655c83ee119e3f170a302a9b35d9ca7efd13bbc226df2d68e5d31 +ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=98a665f59bced8f7c084e786280100e0571390b04eeffad140e633b463d8a204 +ARG COMPOSE_EXECUTOR_RELEASE_BASE_URL=https://github.com/PastureStack/compose-cli/releases/download +ARG COMPOSE_EXECUTOR_VERSION=0.14.37 +ARG COMPOSE_EXECUTOR_ARCHIVE_SHA256=5ff465601930218f531885a384f231d969033e4cef794ce33d16bfe24e5c1c81 +ARG COMPOSE_EXECUTOR_BINARY_SHA256=a9bf9f0f77e914fe557d3e178a73c31526b0ca0adc4afbf17bf68d8d75c7ee27 +ARG VSPHERE_CLI_BUNDLE_RELEASE_BASE_URL=https://github.com/PastureStack/vsphere-cli-bundle/releases/download +ARG VSPHERE_CLI_BUNDLE_VERSION=0.55.3 +ARG VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256=31be702e515741686e2c665d387562e5e993c5d2ffbdb2d614ed287243b166e4 +ARG GOVC_BINARY_SHA256=d3c4f4fab44403ec4110743b52da99f5ce3d7e3773c4661db8a87dec3ead8990 +ARG HOST_PROVISIONER_RELEASE_BASE_URL=https://github.com/PastureStack/host-provisioner/releases/download +ARG HOST_PROVISIONER_VERSION=0.39.8 +ARG HOST_PROVISIONER_COMMIT=385e5b536c108f17fdfcdec84c01750a5be5ab1c +ARG HOST_PROVISIONER_ARCHIVE_SHA256=d775f36a613b1a486a5e60d6ad61fdbd1ebb4bd22422cbb6dcb70fdd7c4abf0c +ARG HOST_PROVISIONER_BINARY_SHA256=1d37e20a7a1cf4f3e36036a15ff7699ef22cd8809fd14a20892034dd054fd1fc +ARG SECRET_DELIVERY_API_RELEASE_BASE_URL=https://github.com/PastureStack/secret-delivery-api/releases/download +ARG SECRET_DELIVERY_API_VERSION=0.3.2 +ARG SECRET_DELIVERY_API_COMMIT=f9c3f933f14e43dc63074d45a3044b693ec1e573 +ARG SECRET_DELIVERY_API_ARCHIVE_SHA256=ca9ab0bfddbcad84b6c1a865af8ad82fcc56cb43dcca1da240ed11a5d24cbdb2 +ARG SECRET_DELIVERY_API_BINARY_SHA256=b5e01b7e65aeee3b456fe043142b5e70f4039ee89ffd437648368ff63e6042b6 +ARG USAGE_TELEMETRY_AGENT_RELEASE_BASE_URL=https://github.com/PastureStack/usage-telemetry-agent/releases/download +ARG USAGE_TELEMETRY_AGENT_VERSION=0.4.2 +ARG USAGE_TELEMETRY_AGENT_COMMIT=40f9af7ca932fedacdb87e30b4ef1c60a4a7444e +ARG USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256=5ce031c84f76b3e62dafdb04fb4ed014aa1921e83be056c2d5dd712acbce25a8 +ARG USAGE_TELEMETRY_AGENT_BINARY_SHA256=e62a21270142181315293d7e11482288ffe8fc4d91cc8fe07dacc90b461f3709 RUN set -eux; \ + for release_base_url in "${COMPOSE_EXECUTOR_RELEASE_BASE_URL}" "${VSPHERE_CLI_BUNDLE_RELEASE_BASE_URL}"; do \ + case "${release_base_url}" in \ + https://*) ;; \ + http://127.0.0.1:*|http://localhost:*) test "${PASTURESTACK_ALLOW_LOOPBACK_ARTIFACTS}" = 1 ;; \ + *) echo 'Release artifact source must use HTTPS or an explicitly allowed loopback address' >&2; exit 1 ;; \ + esac; \ + done; \ printf '%s\n' "${WEB_CONSOLE_ARTIFACT_SHA256}" | grep -Eq '^[0-9a-f]{64}$'; \ apt-get update; \ apt-get install -y --no-install-recommends xz-utils; \ @@ -74,21 +125,22 @@ RUN set -eux; \ test -n "${WEB_CONSOLE_ARTIFACT_SHA256}"; \ web_archive=/tmp/web-console.tar.gz; \ web_listing=/tmp/web-console-files.txt; \ - curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - -o "${web_archive}" \ - "${WEB_CONSOLE_RELEASE_BASE_URL%/}/${WEB_CONSOLE_RELEASE_TAG}/${WEB_CONSOLE_ARTIFACT}"; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "${WEB_CONSOLE_RELEASE_BASE_URL%/}/${WEB_CONSOLE_RELEASE_TAG}/${WEB_CONSOLE_ARTIFACT}" \ + "${WEB_CONSOLE_ARTIFACT}" "${WEB_CONSOLE_ARTIFACT_SHA256}" "${web_archive}"; \ echo "${WEB_CONSOLE_ARTIFACT_SHA256} ${web_archive}" | sha256sum -c -; \ tar -tzf "${web_archive}" >"${web_listing}"; \ if grep -Eq '(^/|(^|/)\.\.(/|$))' "${web_listing}"; then \ echo 'Unsafe Web Console archive path' >&2; \ exit 1; \ fi; \ - if sort "${web_listing}" | uniq -d | grep -q .; then \ + sort "${web_listing}" | uniq -d > /tmp/pasturestack-archive-duplicates.txt; \ + if test -s /tmp/pasturestack-archive-duplicates.txt; then \ echo 'Duplicate Web Console archive path' >&2; \ exit 1; \ fi; \ - if tar -tvzf "${web_archive}" | grep -Eq '^[lh]'; then \ + tar -tvzf "${web_archive}" > /tmp/pasturestack-archive-verbose.txt; \ + if grep -Eq '^[lh]' /tmp/pasturestack-archive-verbose.txt; then \ echo 'Web Console archive may not contain links' >&2; \ exit 1; \ fi; \ @@ -117,6 +169,10 @@ RUN set -eux; \ grep -aF 'resourceLoadError.secretsUnavailable' "${ui_entry}" >/dev/null; \ grep -aF 'resourceLoadError.secretsFailed' "${ui_entry}" >/dev/null; \ grep -aF 'growl-mount' "${ui_entry}" >/dev/null; \ + for key_policy_marker in ui/utils/api-key-policy-matrix ui/components/api-key-policy-matrix/template \ + data-policy-matrix data-access-default data-matrix-basis; do \ + grep -aF "${key_policy_marker}" "${ui_entry}" >/dev/null; \ + done; \ for permission_key in routePermission.title routePermission.denied routePermission.updateDenied; do \ grep -aF "${permission_key}" "${ui_entry}" >/dev/null; \ done; \ @@ -176,14 +232,14 @@ RUN set -eux; \ done; \ catalog_archive=/tmp/catalog-service.tar.xz; \ catalog_listing=/tmp/catalog-service-files.txt; \ - curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - -o "${catalog_archive}" \ - "${CATALOG_SERVICE_RELEASE_BASE_URL%/}/v${CATALOG_SERVICE_VERSION}/catalog-service-${CATALOG_SERVICE_VERSION}.tar.xz"; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "${CATALOG_SERVICE_RELEASE_BASE_URL%/}/v${CATALOG_SERVICE_VERSION}/catalog-service-${CATALOG_SERVICE_VERSION}.tar.xz" \ + "catalog-service-${CATALOG_SERVICE_VERSION}.tar.xz" "${CATALOG_SERVICE_ARCHIVE_SHA256}" "${catalog_archive}"; \ echo "${CATALOG_SERVICE_ARCHIVE_SHA256} ${catalog_archive}" | sha256sum -c -; \ tar -tJf "${catalog_archive}" >"${catalog_listing}"; \ printf './\n./catalog-service\n./catalog-service-sqlite\n' | cmp - "${catalog_listing}"; \ - if tar -tvJf "${catalog_archive}" | grep -Eq '^[lh]'; then \ + tar -tvJf "${catalog_archive}" > /tmp/pasturestack-archive-verbose.txt; \ + if grep -Eq '^[lh]' /tmp/pasturestack-archive-verbose.txt; then \ echo 'Catalog Service archive may not contain links' >&2; \ exit 1; \ fi; \ @@ -198,10 +254,9 @@ RUN set -eux; \ go version -m "/out/catalog-service/${catalog_binary}" | grep -F 'vcs.modified=false'; \ done; \ mkdir -p /out/catalog-service-licenses; \ - curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - -o /out/catalog-service-licenses/LICENSE.txt \ - "https://raw.githubusercontent.com/PastureStack/catalog-service/${CATALOG_SERVICE_COMMIT}/LICENSE"; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "https://raw.githubusercontent.com/PastureStack/catalog-service/${CATALOG_SERVICE_COMMIT}/LICENSE" \ + "catalog-service-${CATALOG_SERVICE_VERSION}-LICENSE.txt" "${CATALOG_SERVICE_LICENSE_SHA256}" /out/catalog-service-licenses/LICENSE.txt; \ echo "${CATALOG_SERVICE_LICENSE_SHA256} /out/catalog-service-licenses/LICENSE.txt" | sha256sum -c -; \ printf 'Catalog Service v%s\nRelease source commit: %s\nArtifact SHA-256: %s\nBinary SHA-256: %s\nSQLite binary SHA-256: %s\nSource: https://github.com/PastureStack/catalog-service/tree/%s\nThe inherited runtime license bundle remains available as historical attribution; these coordinates identify the replacement Catalog binaries.\n' \ "${CATALOG_SERVICE_VERSION}" "${CATALOG_SERVICE_COMMIT}" \ @@ -210,15 +265,15 @@ RUN set -eux; \ > /out/catalog-service-licenses/SOURCES.txt; \ auth_archive=/tmp/authentication-service.tar.xz; \ auth_listing=/tmp/authentication-service-files.txt; \ - curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - -o "${auth_archive}" \ - "${AUTHENTICATION_SERVICE_RELEASE_BASE_URL%/}/v${AUTHENTICATION_SERVICE_VERSION}/authentication-service-${AUTHENTICATION_SERVICE_VERSION}-linux-amd64.tar.xz"; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "${AUTHENTICATION_SERVICE_RELEASE_BASE_URL%/}/v${AUTHENTICATION_SERVICE_VERSION}/authentication-service-${AUTHENTICATION_SERVICE_VERSION}-linux-amd64.tar.xz" \ + "authentication-service-${AUTHENTICATION_SERVICE_VERSION}-linux-amd64.tar.xz" "${AUTHENTICATION_SERVICE_ARCHIVE_SHA256}" "${auth_archive}"; \ echo "${AUTHENTICATION_SERVICE_ARCHIVE_SHA256} ${auth_archive}" | sha256sum -c -; \ tar -tJf "${auth_archive}" >"${auth_listing}"; \ test "$(cat "${auth_listing}")" = authentication-service; \ test "$(wc -l <"${auth_listing}")" -eq 1; \ - if tar -tvJf "${auth_archive}" | grep -Eq '^[lh]'; then \ + tar -tvJf "${auth_archive}" > /tmp/pasturestack-archive-verbose.txt; \ + if grep -Eq '^[lh]' /tmp/pasturestack-archive-verbose.txt; then \ echo 'Authentication Service archive may not contain links' >&2; \ exit 1; \ fi; \ @@ -231,15 +286,15 @@ RUN set -eux; \ grep -F "${AUTHENTICATION_SERVICE_VERSION}" >/dev/null; \ websocket_archive=/tmp/websocket-proxy.tar.xz; \ websocket_listing=/tmp/websocket-proxy-files.txt; \ - curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - -o "${websocket_archive}" \ - "${WEBSOCKET_PROXY_RELEASE_BASE_URL%/}/v${WEBSOCKET_PROXY_VERSION}/websocket-proxy-${WEBSOCKET_PROXY_VERSION}-linux-amd64.tar.xz"; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "${WEBSOCKET_PROXY_RELEASE_BASE_URL%/}/v${WEBSOCKET_PROXY_VERSION}/websocket-proxy-${WEBSOCKET_PROXY_VERSION}-linux-amd64.tar.xz" \ + "websocket-proxy-${WEBSOCKET_PROXY_VERSION}-linux-amd64.tar.xz" "${WEBSOCKET_PROXY_ARCHIVE_SHA256}" "${websocket_archive}"; \ echo "${WEBSOCKET_PROXY_ARCHIVE_SHA256} ${websocket_archive}" | sha256sum -c -; \ tar -tJf "${websocket_archive}" >"${websocket_listing}"; \ test "$(cat "${websocket_listing}")" = websocket-proxy; \ test "$(wc -l <"${websocket_listing}")" -eq 1; \ - if tar -tvJf "${websocket_archive}" | grep -Eq '^[lh]'; then \ + tar -tvJf "${websocket_archive}" > /tmp/pasturestack-archive-verbose.txt; \ + if grep -Eq '^[lh]' /tmp/pasturestack-archive-verbose.txt; then \ echo 'WebSocket Proxy archive may not contain links' >&2; \ exit 1; \ fi; \ @@ -250,10 +305,9 @@ RUN set -eux; \ webhook_archive=/tmp/webhook-automation-service.tar.xz; \ webhook_listing=/tmp/webhook-automation-service-files.txt; \ webhook_expected=/tmp/webhook-automation-service-expected.txt; \ - curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - -o "${webhook_archive}" \ - "${WEBHOOK_AUTOMATION_SERVICE_RELEASE_BASE_URL%/}/v${WEBHOOK_AUTOMATION_SERVICE_VERSION}/webhook-automation-service-${WEBHOOK_AUTOMATION_SERVICE_VERSION}-linux-amd64.tar.xz"; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "${WEBHOOK_AUTOMATION_SERVICE_RELEASE_BASE_URL%/}/v${WEBHOOK_AUTOMATION_SERVICE_VERSION}/webhook-automation-service-${WEBHOOK_AUTOMATION_SERVICE_VERSION}-linux-amd64.tar.xz" \ + "webhook-automation-service-${WEBHOOK_AUTOMATION_SERVICE_VERSION}-linux-amd64.tar.xz" "${WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256}" "${webhook_archive}"; \ echo "${WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256} ${webhook_archive}" | sha256sum -c -; \ tar -tJf "${webhook_archive}" >"${webhook_listing}"; \ printf '%s\n' \ @@ -263,7 +317,8 @@ RUN set -eux; \ webhook-automation-service-SOURCES.txt \ webhook-automation-service-THIRD-PARTY-NOTICES.md | LC_ALL=C sort >"${webhook_expected}"; \ LC_ALL=C sort "${webhook_listing}" | cmp "${webhook_expected}" -; \ - if tar -tvJf "${webhook_archive}" | grep -Eq '^[lh]'; then \ + tar -tvJf "${webhook_archive}" > /tmp/pasturestack-archive-verbose.txt; \ + if grep -Eq '^[lh]' /tmp/pasturestack-archive-verbose.txt; then \ echo 'Webhook Automation Service archive may not contain links' >&2; \ exit 1; \ fi; \ @@ -274,13 +329,12 @@ RUN set -eux; \ chmod 0755 /out/webhook-automation-service/webhook-automation-service; \ /out/webhook-automation-service/webhook-automation-service --version | \ grep -Fx "webhook-automation-service version ${WEBHOOK_AUTOMATION_SERVICE_VERSION}"; \ - grep -Fx 'Release source commit: fbcc0ca07e42e9b21bda18031d0848192ec2f9a1' \ + grep -Fx 'Release source commit: 400118b893843d2a7d7c65cc70c3449d76c4a8d8' \ /out/webhook-automation-service/webhook-automation-service-SOURCES.txt; \ compose_archive=/tmp/compose-executor.gz; \ - curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - -o "${compose_archive}" \ - "https://github.com/PastureStack/compose-cli/releases/download/v${COMPOSE_EXECUTOR_VERSION}/compose-executor-${COMPOSE_EXECUTOR_VERSION}-linux-amd64.gz"; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "${COMPOSE_EXECUTOR_RELEASE_BASE_URL%/}/v${COMPOSE_EXECUTOR_VERSION}/compose-executor-${COMPOSE_EXECUTOR_VERSION}-linux-amd64.gz" \ + "compose-executor-${COMPOSE_EXECUTOR_VERSION}-linux-amd64.gz" "${COMPOSE_EXECUTOR_ARCHIVE_SHA256}" "${compose_archive}"; \ echo "${COMPOSE_EXECUTOR_ARCHIVE_SHA256} ${compose_archive}" | sha256sum -c -; \ gzip -dc "${compose_archive}" >/out/compose-executor; \ chmod 0755 /out/compose-executor; \ @@ -288,29 +342,29 @@ RUN set -eux; \ compose_version_output="$(/out/compose-executor --version 2>&1)"; \ test "${compose_version_output}" = "compose-executor version ${COMPOSE_EXECUTOR_VERSION}"; \ engine_archive=/out/orchestration-engine.jar; \ - curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - -o "${engine_archive}" \ - "${ORCHESTRATION_ENGINE_RELEASE_BASE_URL%/}/${ORCHESTRATION_ENGINE_RELEASE_TAG}/${ORCHESTRATION_ENGINE_ARTIFACT}"; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "${ORCHESTRATION_ENGINE_RELEASE_BASE_URL%/}/${ORCHESTRATION_ENGINE_RELEASE_TAG}/${ORCHESTRATION_ENGINE_ARTIFACT}" \ + "${ORCHESTRATION_ENGINE_ARTIFACT}" "${ORCHESTRATION_ENGINE_ARTIFACT_SHA256}" "${engine_archive}"; \ echo "${ORCHESTRATION_ENGINE_ARTIFACT_SHA256} ${engine_archive}" | sha256sum -c -; \ vsphere_archive=/tmp/vsphere-cli-bundle.tar.xz; \ vsphere_listing=/tmp/vsphere-cli-bundle-files.txt; \ vsphere_expected=/tmp/vsphere-cli-bundle-expected.txt; \ - curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - -o "${vsphere_archive}" \ - "https://github.com/PastureStack/vsphere-cli-bundle/releases/download/v${VSPHERE_CLI_BUNDLE_VERSION}/vsphere-cli-bundle-${VSPHERE_CLI_BUNDLE_VERSION}-linux-amd64.tar.xz"; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "${VSPHERE_CLI_BUNDLE_RELEASE_BASE_URL%/}/v${VSPHERE_CLI_BUNDLE_VERSION}/vsphere-cli-bundle-${VSPHERE_CLI_BUNDLE_VERSION}-linux-amd64.tar.xz" \ + "vsphere-cli-bundle-${VSPHERE_CLI_BUNDLE_VERSION}-linux-amd64.tar.xz" "${VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256}" "${vsphere_archive}"; \ echo "${VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256} ${vsphere_archive}" | sha256sum -c -; \ tar -tJf "${vsphere_archive}" >"${vsphere_listing}"; \ if grep -Eq '(^/|(^|/)\.\.(/|$))' "${vsphere_listing}"; then \ echo 'Unsafe vSphere CLI bundle archive path' >&2; \ exit 1; \ fi; \ - if sort "${vsphere_listing}" | uniq -d | grep -q .; then \ + sort "${vsphere_listing}" | uniq -d > /tmp/pasturestack-archive-duplicates.txt; \ + if test -s /tmp/pasturestack-archive-duplicates.txt; then \ echo 'Duplicate vSphere CLI bundle archive path' >&2; \ exit 1; \ fi; \ - if tar -tvJf "${vsphere_archive}" | grep -Eq '^[lh]'; then \ + tar -tvJf "${vsphere_archive}" > /tmp/pasturestack-archive-verbose.txt; \ + if grep -Eq '^[lh]' /tmp/pasturestack-archive-verbose.txt; then \ echo 'vSphere CLI bundle archive may not contain links' >&2; \ exit 1; \ fi; \ @@ -328,29 +382,80 @@ RUN set -eux; \ test "$(/out/vsphere-cli-bundle/govc version)" = "govc ${VSPHERE_CLI_BUNDLE_VERSION}"; \ grep -Fx 'Pinned source commit: a668d9c60399552ea96782b8751c956720a0b8fb' \ /out/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt >/dev/null; \ - grep -Fx 'Build Go version: 1.27.0' \ + grep -Fx 'Build Go version: 1.27.2' \ /out/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt >/dev/null; \ grep -Fx "Binary SHA-256: ${GOVC_BINARY_SHA256}" \ + /out/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt >/dev/null; \ + grep -Fx 'Security dependency: golang.org/x/text v0.41.0' \ + /out/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt >/dev/null; \ + grep -Fx 'Dependency-only override: govc/go.mod and govc/go.sum; upstream Go source unchanged' \ /out/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt >/dev/null +RUN set -eux; \ + for component in host-provisioner secret-delivery-api usage-telemetry-agent; do \ + case "$component" in \ + host-provisioner) version="${HOST_PROVISIONER_VERSION}"; commit="${HOST_PROVISIONER_COMMIT}"; archive_sha="${HOST_PROVISIONER_ARCHIVE_SHA256}"; binary_sha="${HOST_PROVISIONER_BINARY_SHA256}"; release_url="${HOST_PROVISIONER_RELEASE_BASE_URL}" ;; \ + secret-delivery-api) version="${SECRET_DELIVERY_API_VERSION}"; commit="${SECRET_DELIVERY_API_COMMIT}"; archive_sha="${SECRET_DELIVERY_API_ARCHIVE_SHA256}"; binary_sha="${SECRET_DELIVERY_API_BINARY_SHA256}"; release_url="${SECRET_DELIVERY_API_RELEASE_BASE_URL}" ;; \ + usage-telemetry-agent) version="${USAGE_TELEMETRY_AGENT_VERSION}"; commit="${USAGE_TELEMETRY_AGENT_COMMIT}"; archive_sha="${USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256}"; binary_sha="${USAGE_TELEMETRY_AGENT_BINARY_SHA256}"; release_url="${USAGE_TELEMETRY_AGENT_RELEASE_BASE_URL}" ;; \ + esac; \ + artifact="${component}-${version}-linux-amd64.tar.xz"; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" "${release_url%/}/v${version}/${artifact}" "${artifact}" "${archive_sha}" "/tmp/${artifact}"; \ + verify-runtime-producer "$component" "$version" "$commit" "/tmp/${artifact}" "$binary_sha" "/out/$component"; \ + done + +# Assemble verified products in the build stage; retain component-specific legal +# directories and .real targets so runtime wrappers remain unchanged. Two COPYs +# replace per-component layers without flattening the multi-stage build. +RUN set -eux; \ + mkdir -p /out/runtime-bin /out/runtime-licenses; \ + install -m 0755 /out/catalog-service/catalog-service /out/runtime-bin/catalog-service.real; \ + install -m 0755 /out/catalog-service/catalog-service-sqlite /out/runtime-bin/catalog-service-sqlite; \ + install -m 0755 /out/authentication-service/authentication-service /out/runtime-bin/authentication-service.real; \ + install -m 0755 /out/websocket-proxy/websocket-proxy /out/runtime-bin/websocket-proxy.real; \ + install -m 0755 /out/webhook-automation-service/webhook-automation-service /out/runtime-bin/webhook-automation-service; \ + install -m 0755 /out/compose-executor /out/runtime-bin/compose-executor.real; \ + install -m 0755 /out/vsphere-cli-bundle/govc /out/runtime-bin/govc; \ + install -m 0755 /out/host-provisioner/host-provisioner /out/runtime-bin/host-provisioner.real; \ + install -m 0755 /out/secret-delivery-api/secret-delivery-api /out/runtime-bin/secret-delivery-api; \ + install -m 0755 /out/usage-telemetry-agent/usage-telemetry-agent /out/runtime-bin/usage-telemetry-agent; \ + cp -a /out/catalog-service-licenses /out/runtime-licenses/catalog-service; \ + cp -a /out/host-provisioner-licenses /out/runtime-licenses/host-provisioner; \ + cp -a /out/secret-delivery-api-licenses /out/runtime-licenses/secret-delivery-api; \ + cp -a /out/usage-telemetry-agent-licenses /out/runtime-licenses/usage-telemetry-agent; \ + mkdir -p /out/runtime-licenses/webhook-automation-service /out/runtime-licenses/vsphere-cli-bundle; \ + cp -a /out/webhook-automation-service/webhook-automation-service-COMPATIBILITY.md \ + /out/webhook-automation-service/webhook-automation-service-LICENSES.txt \ + /out/webhook-automation-service/webhook-automation-service-SOURCES.txt \ + /out/webhook-automation-service/webhook-automation-service-THIRD-PARTY-NOTICES.md \ + /out/runtime-licenses/webhook-automation-service/; \ + cp -a /out/vsphere-cli-bundle/vsphere-cli-bundle-LICENSES.txt \ + /out/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt \ + /out/vsphere-cli-bundle/vsphere-cli-bundle-THIRD-PARTY-NOTICES.txt \ + /out/runtime-licenses/vsphere-cli-bundle/; \ + test "$(find /out/runtime-bin -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 10; \ + test "$(find /out/runtime-licenses -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 6; \ + test -z "$(find /out/runtime-bin /out/runtime-licenses -type l -print -quit)" + RUN set -eux; \ test -n "${SOURCE_DATE_EPOCH}"; \ - curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - -o /tmp/host-api-original.tar.gz \ - https://github.com/PastureStack/server/releases/download/v1.6.278/host-api-0.38.4.tar.gz; \ - echo '4c62c4130e65d2c00f9bb021929dbb178d43bcd155c4be6da344f3856121c49c /tmp/host-api-original.tar.gz' | sha256sum -c -; \ - repair-host-api-sha256 \ - /tmp/host-api-original.tar.gz \ - /out/host-api-0.38.4.tar.gz \ - "${SOURCE_DATE_EPOCH}"; \ - mkdir /tmp/host-api-check; \ - tar --no-same-owner --no-same-permissions \ - -xzf /out/host-api-0.38.4.tar.gz -C /tmp/host-api-check; \ - cd /tmp/host-api-check; \ - sha256sum -c 301025de31c07073cc03efa17e1ab8ef/SHA256SUMSSUM; \ - sha256sum -c 301025de31c07073cc03efa17e1ab8ef/SHA256SUMS; \ - sha256sum /out/host-api-0.38.4.tar.gz + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "${HOST_API_RELEASE_BASE_URL%/}/${HOST_API_RELEASE_TAG}/host-api-${HOST_API_VERSION}.tar.gz" \ + "host-api-${HOST_API_VERSION}.tar.gz" "${HOST_API_ARCHIVE_SHA256}" /tmp/host-api-original.tar.gz; \ + if [ "${HOST_API_PACKAGE_MODE}" = legacy-repair ]; then \ + test "${HOST_API_VERSION}" = 0.38.4; \ + repair-host-api-sha256 /tmp/host-api-original.tar.gz /out/host-api.tar.gz "${SOURCE_DATE_EPOCH}"; \ + else \ + test "${HOST_API_PACKAGE_MODE}" = producer; \ + cp /tmp/host-api-original.tar.gz /out/host-api.tar.gz; \ + fi; \ + verify-host-api-package /out/host-api.tar.gz "${HOST_API_PACKAGE_ID}" "${HOST_API_BINARY_SHA256}" "${HOST_API_APPLY_SHA256}" "${HOST_API_COMMIT}" + +RUN set -eux; \ + fetch-server-component "${COMPONENT_ARTIFACT_MODE}" \ + "${NODE_AGENT_RELEASE_BASE_URL%/}/v${NODE_AGENT_VERSION}/node-agent-${NODE_AGENT_VERSION}.tar.gz" \ + "node-agent-${NODE_AGENT_VERSION}.tar.gz" "${NODE_AGENT_ARCHIVE_SHA256}" /out/node-agent.tar.gz; \ + verify-agent-package.sh node-agent /out/node-agent.tar.gz "${NODE_AGENT_PACKAGE_ID}" \ + "${NODE_AGENT_BINARY_SHA256}" "${NODE_AGENT_APPLY_SHA256}" "${NODE_AGENT_COMMIT}" FROM ${UBUNTU_SECURITY_IMAGE} AS runtime_security_packages @@ -359,6 +464,7 @@ ARG CURL_PACKAGE_VERSION=8.18.0-1ubuntu2.7 ARG OPENSSL_PACKAGE_VERSION=3.5.5-1ubuntu3.7 ARG LIBDBI_PERL_PACKAGE_VERSION=1.647-1ubuntu0.26.04.3 ARG FREETYPE_PACKAGE_VERSION=2.14.2+dfsg-1ubuntu0.2 +ARG LIBPNG_PACKAGE_VERSION=1.6.57-1ubuntu0.1 ENV DEBIAN_FRONTEND=noninteractive WORKDIR /build ADD --checksum=sha256:6077d27c6b6f8b23590cb01ff877ed8c804a67a5442cc32b5a33da10d2bd0e90 \ @@ -367,6 +473,9 @@ ADD --checksum=sha256:6077d27c6b6f8b23590cb01ff877ed8c804a67a5442cc32b5a33da10d2 ADD --checksum=sha256:6d7d532b7d0c57639deb3b228f1f0d786cf5305d613ef7df9ba76c776a0f8373 \ https://security.ubuntu.com/ubuntu/pool/main/f/freetype/libfreetype6_2.14.2+dfsg-1ubuntu0.2_amd64.deb \ /tmp/libfreetype6.deb +ADD --checksum=sha256:f24a7f7c0428d74e33ea934696987b7336ec0962f212535185f0493792e7536e \ + https://security.ubuntu.com/ubuntu/pool/main/libp/libpng1.6/libpng16-16t64_1.6.57-1ubuntu0.1_amd64.deb \ + /tmp/libpng16.deb RUN set -eux; \ mkdir -p /tmp/ca-bootstrap /etc/ssl/certs; \ dpkg-deb --extract /tmp/ca-certificates.deb /tmp/ca-bootstrap; \ @@ -390,6 +499,14 @@ RUN set -eux; \ cd /tmp/freetype-runtime; \ sha256sum usr/lib/x86_64-linux-gnu/libfreetype.so.6 > /out/freetype-runtime.sha256; \ test "$(wc -l < /out/freetype-runtime.sha256)" -eq 1; \ + test "$(dpkg-deb -f /tmp/libpng16.deb Package)" = libpng16-16t64; \ + test "$(dpkg-deb -f /tmp/libpng16.deb Version)" = "${LIBPNG_PACKAGE_VERSION}"; \ + test "$(dpkg-deb -f /tmp/libpng16.deb Architecture)" = amd64; \ + cp /tmp/libpng16.deb "/out/packages/libpng16-16t64_${LIBPNG_PACKAGE_VERSION}_amd64.deb"; \ + dpkg-deb --extract /tmp/libpng16.deb /tmp/libpng-runtime; \ + cd /tmp/libpng-runtime; \ + sha256sum usr/lib/x86_64-linux-gnu/libpng16.so.16 > /out/libpng-runtime.sha256; \ + test "$(wc -l < /out/libpng-runtime.sha256)" -eq 1; \ cd /out/packages; \ apt-get download \ "curl=${CURL_PACKAGE_VERSION}" \ @@ -442,50 +559,75 @@ RUN set -eux; \ > /out/openssl-runtime.sha256; \ cp /usr/bin/tar /out/tar; \ cd /out; \ - sha256sum packages/*.deb tar openssl-runtime.sha256 libdbi-perl-runtime.sha256 freetype-runtime.sha256 > SHA256SUMS + sha256sum packages/*.deb tar openssl-runtime.sha256 libdbi-perl-runtime.sha256 freetype-runtime.sha256 libpng-runtime.sha256 > SHA256SUMS FROM ${BASE_IMAGE} ARG SOURCE_DATE_EPOCH ARG PASTURESTACK_SERVER_REVISION +ARG SERVER_RELEASE_TAG=v1.6.519 +ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.335 +ARG ORCHESTRATION_ENGINE_VERSION=0.183.335 +ARG ENGINE_READONLY_SCHEMA_SHA256=7f274219e8dd9c6d750a408a6edec1448b564f4b16ba2204c425c5b45cec2233 +ARG ENGINE_RESTRICTED_SCHEMA_SHA256=f854ba99260f29e324ab7446a8592996946038920ac9bcf17689d98d1ebc5e51 +ARG HOST_API_VERSION=0.38.5 +ARG HOST_API_PACKAGE_ID=84754fe8bc79027d0c72f492923c4786 +ARG HOST_API_COMMIT=84754fe8bc79027d0c72f492923c47861aa74b99 +ARG HOST_API_ARCHIVE_SHA256=ba111038695aa03a82c51d60944bbbdbbec30bb269051ba29fa21d46ada17179 +ARG NODE_AGENT_VERSION=0.13.28 +ARG NODE_AGENT_COMMIT=bb4e057c089e5384bc9836b42db206326c690387 +ARG NODE_AGENT_ARCHIVE_SHA256=a16ae6be5fb72fa2ba38bd1076fd3b9e0536c48e70995ffa49d17b8063e2f8d0 ARG UBUNTU_RUNTIME_SECURITY_SNAPSHOT=20261002T000000Z ARG CURL_PACKAGE_VERSION=8.18.0-1ubuntu2.7 ARG OPENSSL_PACKAGE_VERSION=3.5.5-1ubuntu3.7 ARG LIBDBI_PERL_PACKAGE_VERSION=1.647-1ubuntu0.26.04.3 ARG FREETYPE_PACKAGE_VERSION=2.14.2+dfsg-1ubuntu0.2 +ARG LIBPNG_PACKAGE_VERSION=1.6.57-1ubuntu0.1 ARG GLIBC_PACKAGE_VERSION=2.43-2ubuntu2.4 ARG PERL_PACKAGE_VERSION=5.40.1-7ubuntu0.3 -ARG WEB_CONSOLE_RELEASE_TAG=1.6.180 -ARG WEB_CONSOLE_ARTIFACT_SHA256=a367bd6907281298a8e2bf0f3ad0444083db06062f3a1521db573cc5606d274e -ARG WEB_CONSOLE_COMMIT=637604b38401b19d2c9ef73d5729d356bb80c8e6 -ARG CATALOG_SERVICE_VERSION=0.20.12 -ARG CATALOG_SERVICE_COMMIT=d708579092eae0fd03b2750ac594ff0396cf563b -ARG CATALOG_SERVICE_ARCHIVE_SHA256=34b76c121270c603501664f7146d41916c7f45da983e326861ed4c5b9614372d -ARG CATALOG_SERVICE_BINARY_SHA256=3deb43f9760d7cbb07f818dd108ab35abf9efc6908d5d7fcaf5c44a185f2fba9 -ARG CATALOG_SERVICE_SQLITE_BINARY_SHA256=8805af3c0b5968a02f994d65de715c525b73637aa2dc398a0648feeeaf2fd397 +ARG WEB_CONSOLE_RELEASE_TAG=1.6.181 +ARG WEB_CONSOLE_ARTIFACT_SHA256=9199681f2c4311f3fbe8727fcf0f6fa136c9bafe9fbe9d13878c55df442a7a03 +ARG WEB_CONSOLE_COMMIT=7f2bdad0c90a3241693c2ddd4475202ababba550 +ARG CATALOG_SERVICE_VERSION=0.20.13 +ARG CATALOG_SERVICE_COMMIT=4c39c73a8131ba06e9ff0aaec3b95cc27e049324 +ARG CATALOG_SERVICE_ARCHIVE_SHA256=29626181cb8489b016e5975ffaddc00b2a32d290b1c0066e833d27fa7a5edf83 +ARG CATALOG_SERVICE_BINARY_SHA256=7224c76e5643130dee047e3f1888ce6845d024307bec74d72eebdc4baeae1aea +ARG CATALOG_SERVICE_SQLITE_BINARY_SHA256=4aeda3e1ee1ca4c57f26938f3ef27651f9c8c129ee12957642960d22b95034ce ARG CATALOG_SERVICE_LICENSE_SHA256=0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594 -ARG AUTHENTICATION_SERVICE_VERSION=0.4.42 -ARG AUTHENTICATION_SERVICE_COMMIT=5589ef8fda68ae56e1afd64096965d452ee8a17e -ARG AUTHENTICATION_SERVICE_ARCHIVE_SHA256=f14d22036a0a88d6a8d669700506bba680fc7605bbca2b337e345c5cd71500fb -ARG AUTHENTICATION_SERVICE_BINARY_SHA256=feaabe4bba85cbe119c98a79a27abb4510401fc051f34d02aa7b48d69bdbe746 -ARG WEBSOCKET_PROXY_VERSION=0.23.14 -ARG WEBSOCKET_PROXY_COMMIT=3b5788bdc52f4edab0097a3d97afccf138c64089 -ARG WEBSOCKET_PROXY_ARCHIVE_SHA256=c55108c3dbfd8e6579fc768a1988920db83c6f605ca1284b60edf92ae8d0160e -ARG WEBSOCKET_PROXY_BINARY_SHA256=efd0c78779a620b4b0f74a10eb3f3edd8886e8d23f22dc4624d8e9971085a26d -ARG WEBHOOK_AUTOMATION_SERVICE_VERSION=0.10.3 -ARG WEBHOOK_AUTOMATION_SERVICE_COMMIT=fbcc0ca07e42e9b21bda18031d0848192ec2f9a1 -ARG WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256=6babbc18cee9a192009cfadcd143e6b9a5f2b550c4dc419781f3e3657caa022a -ARG WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256=9094f3b2527762a3e683b02d93aa00e52618e902cd409e72e34553d98d98a609 -ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=8c42c0982cbc2f4569fa265ad320b341551758cb4fc0bc6d79ba06d70e20d328 -ARG ORCHESTRATION_ENGINE_COMMIT=0d94f7d879d314235e582a7f4062914a27b82709 -ARG COMPOSE_EXECUTOR_VERSION=0.14.36 -ARG COMPOSE_EXECUTOR_COMMIT=e85545a1bc34cb5c42db62ff90dd82b7ff9f5838 -ARG COMPOSE_EXECUTOR_ARCHIVE_SHA256=47e2ba1686c1b136c7edcac530495c3e29c351e947f0b4d08ebe68d33f98cf66 -ARG COMPOSE_EXECUTOR_BINARY_SHA256=1f542ee2dd76c7af06bc5f056c381d7e77aecaeac40f8d897df6df24a9902c0d -ARG VSPHERE_CLI_BUNDLE_VERSION=0.55.2 -ARG VSPHERE_CLI_BUNDLE_COMMIT=c4b27e87aa0dacce432a2c6108ee0752319e6d5b -ARG VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256=bebcc1c0275072ac40b5bc9b80f914c40a7f0431fffebc2c06fe34a34c33a57c -ARG GOVC_BINARY_SHA256=f8c7d82a614655c83ee119e3f170a302a9b35d9ca7efd13bbc226df2d68e5d31 +ARG AUTHENTICATION_SERVICE_VERSION=0.4.43 +ARG AUTHENTICATION_SERVICE_COMMIT=cae736f377019bd9743648a8e9aa469a0e21b5e0 +ARG AUTHENTICATION_SERVICE_ARCHIVE_SHA256=e9218771af8dd68323c8c6fdab149c40a3ad02da9ff23f8aad6f0b2977740273 +ARG AUTHENTICATION_SERVICE_BINARY_SHA256=fe11eec4b31b43863b49a582b1dbbe309eae08fb78adc150037981174f0622da +ARG WEBSOCKET_PROXY_VERSION=0.23.15 +ARG WEBSOCKET_PROXY_COMMIT=1928f602b66443cdab40c8cdb450c811548d2749 +ARG WEBSOCKET_PROXY_ARCHIVE_SHA256=4657338973f672f6ae4d6e5510d06e811b9951afea1baa27a9caa3034e487f2a +ARG WEBSOCKET_PROXY_BINARY_SHA256=9111d5a569b6326d7cd71fc3384251d9684972492a22e1e9dbbb9863019fbaaa +ARG WEBHOOK_AUTOMATION_SERVICE_VERSION=0.10.4 +ARG WEBHOOK_AUTOMATION_SERVICE_COMMIT=400118b893843d2a7d7c65cc70c3449d76c4a8d8 +ARG WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256=49c4579829a04e758045fae02a5a9fca12bb0ba3af0d5e979cf9eb97f23a88a9 +ARG WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256=98c7faea665b7eb95206b8c73a6f47d644c5d2d0eae53f274f6a15faf5205744 +ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=98a665f59bced8f7c084e786280100e0571390b04eeffad140e633b463d8a204 +ARG ORCHESTRATION_ENGINE_COMMIT=8fd5e7d3021507647064da1ae9c9c3a6eeb4c725 +ARG HOST_PROVISIONER_VERSION=0.39.8 +ARG HOST_PROVISIONER_COMMIT=385e5b536c108f17fdfcdec84c01750a5be5ab1c +ARG HOST_PROVISIONER_ARCHIVE_SHA256=d775f36a613b1a486a5e60d6ad61fdbd1ebb4bd22422cbb6dcb70fdd7c4abf0c +ARG HOST_PROVISIONER_BINARY_SHA256=1d37e20a7a1cf4f3e36036a15ff7699ef22cd8809fd14a20892034dd054fd1fc +ARG SECRET_DELIVERY_API_VERSION=0.3.2 +ARG SECRET_DELIVERY_API_COMMIT=f9c3f933f14e43dc63074d45a3044b693ec1e573 +ARG SECRET_DELIVERY_API_ARCHIVE_SHA256=ca9ab0bfddbcad84b6c1a865af8ad82fcc56cb43dcca1da240ed11a5d24cbdb2 +ARG SECRET_DELIVERY_API_BINARY_SHA256=b5e01b7e65aeee3b456fe043142b5e70f4039ee89ffd437648368ff63e6042b6 +ARG USAGE_TELEMETRY_AGENT_VERSION=0.4.2 +ARG USAGE_TELEMETRY_AGENT_COMMIT=40f9af7ca932fedacdb87e30b4ef1c60a4a7444e +ARG USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256=5ce031c84f76b3e62dafdb04fb4ed014aa1921e83be056c2d5dd712acbce25a8 +ARG USAGE_TELEMETRY_AGENT_BINARY_SHA256=e62a21270142181315293d7e11482288ffe8fc4d91cc8fe07dacc90b461f3709 +ARG COMPOSE_EXECUTOR_VERSION=0.14.37 +ARG COMPOSE_EXECUTOR_COMMIT=88e991e823f06d2334c07d5370595aae3c48ee99 +ARG COMPOSE_EXECUTOR_ARCHIVE_SHA256=5ff465601930218f531885a384f231d969033e4cef794ce33d16bfe24e5c1c81 +ARG COMPOSE_EXECUTOR_BINARY_SHA256=a9bf9f0f77e914fe557d3e178a73c31526b0ca0adc4afbf17bf68d8d75c7ee27 +ARG VSPHERE_CLI_BUNDLE_VERSION=0.55.3 +ARG VSPHERE_CLI_BUNDLE_COMMIT=f48ab9fd9990132c85845fc162186a04f1e0418d +ARG VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256=31be702e515741686e2c665d387562e5e993c5d2ffbdb2d614ed287243b166e4 +ARG GOVC_BINARY_SHA256=d3c4f4fab44403ec4110743b52da99f5ce3d7e3773c4661db8a87dec3ead8990 COPY --from=runtime_security_packages /out/ /tmp/pasturestack-runtime-security/ COPY --from=runtime_security_packages /out/tar /usr/bin/tar @@ -502,12 +644,18 @@ RUN set -eux; \ done; \ test "$(dpkg-query -W -f='${Version}' libdbi-perl)" = "${LIBDBI_PERL_PACKAGE_VERSION}"; \ test "$(dpkg-query -W -f='${Version}' libfreetype6)" = "${FREETYPE_PACKAGE_VERSION}"; \ + test "$(dpkg-query -W -f='${Version}' libpng16-16t64)" = "${LIBPNG_PACKAGE_VERSION}"; \ install -D -m 0644 libdbi-perl-runtime.sha256 /usr/share/pasturestack/security/libdbi-perl-runtime.sha256; \ install -D -m 0644 freetype-runtime.sha256 /usr/share/pasturestack/security/freetype-runtime.sha256; \ + install -D -m 0644 libpng-runtime.sha256 /usr/share/pasturestack/security/libpng-runtime.sha256; \ install -D -m 0644 openssl-runtime.sha256 /usr/share/pasturestack/security/openssl-runtime.sha256; \ cd /; \ test "$(wc -l < /usr/share/pasturestack/security/freetype-runtime.sha256)" -eq 1; \ sha256sum -c /usr/share/pasturestack/security/freetype-runtime.sha256; \ + test "$(wc -l < /usr/share/pasturestack/security/libpng-runtime.sha256)" -eq 1; \ + sha256sum -c /usr/share/pasturestack/security/libpng-runtime.sha256; \ + libpng_linkage="$(ldd -r /usr/lib/x86_64-linux-gnu/libpng16.so.16 2>&1)"; \ + ! printf '%s\n' "${libpng_linkage}" | grep -E 'not found|undefined symbol'; \ test "$(readlink -f /usr/lib/x86_64-linux-gnu/libfreetype.so.6)" = /usr/lib/x86_64-linux-gnu/libfreetype.so.6.20.5; \ freetype_linkage="$(ldd -r /usr/lib/x86_64-linux-gnu/libfreetype.so.6 2>&1)"; \ ! printf '%s\n' "${freetype_linkage}" | grep -E 'not found|undefined symbol'; \ @@ -521,22 +669,23 @@ RUN set -eux; \ rm -f /usr/bin/tar COPY --from=release_artifacts /out/web-console/ /tmp/pasturestack-web-console/ -COPY --from=release_artifacts --chmod=0755 /out/catalog-service/catalog-service /usr/bin/catalog-service.real -COPY --from=release_artifacts --chmod=0755 /out/catalog-service/catalog-service-sqlite /usr/bin/catalog-service-sqlite -COPY --from=release_artifacts /out/catalog-service-licenses/ /usr/share/licenses/pasturestack/catalog-service/ -COPY --from=release_artifacts --chmod=0755 /out/authentication-service/authentication-service /usr/bin/authentication-service.real -COPY --from=release_artifacts --chmod=0755 /out/websocket-proxy/websocket-proxy /usr/bin/websocket-proxy.real - -COPY --from=release_artifacts --chmod=0755 /out/webhook-automation-service/webhook-automation-service /usr/bin/webhook-automation-service - -COPY --from=release_artifacts /out/webhook-automation-service/webhook-automation-service-COMPATIBILITY.md /out/webhook-automation-service/webhook-automation-service-LICENSES.txt /out/webhook-automation-service/webhook-automation-service-SOURCES.txt /out/webhook-automation-service/webhook-automation-service-THIRD-PARTY-NOTICES.md /usr/share/licenses/pasturestack/webhook-automation-service/ -COPY --from=release_artifacts --chmod=0755 /out/compose-executor /usr/bin/compose-executor.real +COPY --from=release_artifacts --chmod=0755 /out/runtime-bin/ /usr/bin/ +COPY --from=release_artifacts /out/runtime-licenses/ /usr/share/licenses/pasturestack/ COPY --from=release_artifacts /out/orchestration-engine.jar /tmp/orchestration-engine.jar -COPY --from=release_artifacts /out/host-api-0.38.4.tar.gz /usr/share/cattle/artifacts/host-api-0.38.4.tar.gz -COPY --from=release_artifacts --chmod=0755 /out/vsphere-cli-bundle/govc /usr/bin/govc -COPY --from=release_artifacts /out/vsphere-cli-bundle/vsphere-cli-bundle-LICENSES.txt /usr/share/licenses/pasturestack/vsphere-cli-bundle/vsphere-cli-bundle-LICENSES.txt -COPY --from=release_artifacts /out/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt /usr/share/licenses/pasturestack/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt -COPY --from=release_artifacts /out/vsphere-cli-bundle/vsphere-cli-bundle-THIRD-PARTY-NOTICES.txt /usr/share/licenses/pasturestack/vsphere-cli-bundle/vsphere-cli-bundle-THIRD-PARTY-NOTICES.txt +COPY --from=release_artifacts /out/host-api.tar.gz /usr/share/cattle/artifacts/host-api-${HOST_API_VERSION}.tar.gz +COPY --from=release_artifacts /out/node-agent.tar.gz /usr/share/cattle/artifacts/node-agent-${NODE_AGENT_VERSION}.tar.gz +RUN set -eux; \ + cd /usr/share/cattle/artifacts; \ + ln -sfn "host-api-${HOST_API_VERSION}.tar.gz" host-api.tar.gz; \ + sed -i '/^export \(DEFAULT_\)\?CATTLE_AGENT_PACKAGE_HOST_API_URL=/d' /usr/share/cattle/env_vars; \ + printf 'export DEFAULT_CATTLE_AGENT_PACKAGE_HOST_API_URL=/usr/share/cattle/artifacts/host-api-%s.tar.gz\nexport CATTLE_AGENT_PACKAGE_HOST_API_URL=/usr/share/cattle/artifacts/host-api-%s.tar.gz\n' \ + "${HOST_API_VERSION}" "${HOST_API_VERSION}" >> /usr/share/cattle/env_vars +RUN set -eux; \ + cd /usr/share/cattle/artifacts; \ + ln -sfn "node-agent-${NODE_AGENT_VERSION}.tar.gz" go-agent.tar.gz; \ + sed -i '/^export \(DEFAULT_\)\?CATTLE_AGENT_PACKAGE_PYTHON_AGENT_URL=/d' /usr/share/cattle/env_vars; \ + printf 'export DEFAULT_CATTLE_AGENT_PACKAGE_PYTHON_AGENT_URL=/usr/share/cattle/artifacts/node-agent-%s.tar.gz\nexport CATTLE_AGENT_PACKAGE_PYTHON_AGENT_URL=/usr/share/cattle/artifacts/node-agent-%s.tar.gz\n' \ + "${NODE_AGENT_VERSION}" "${NODE_AGENT_VERSION}" >> /usr/share/cattle/env_vars COPY --from=console_broker_build --chmod=0755 /out/pasturestack-console-broker /usr/bin/pasturestack-console-broker COPY patches/service-auth.json /tmp/pasturestack-server-overlays/schema/service/service-auth.json COPY patches/bootstrap.sh /tmp/pasturestack-server-overlays/config-content/bootstrap/bootstrap.sh @@ -557,12 +706,13 @@ RUN set -eux; \ echo 'Unsafe Orchestration Engine JAR path' >&2; \ exit 1; \ fi; \ - if sort "${listing}" | uniq -d | grep -q .; then \ + sort "${listing}" | uniq -d > /tmp/pasturestack-archive-duplicates.txt; \ + if test -s /tmp/pasturestack-archive-duplicates.txt; then \ echo 'Duplicate Orchestration Engine JAR path' >&2; \ exit 1; \ fi; \ unzip -p "${archive}" META-INF/MANIFEST.MF | tr -d '\r' | \ - grep -Fx 'Implementation-Version: 0.183.333' >/dev/null; \ + grep -Fx "Implementation-Version: ${ORCHESTRATION_ENGINE_VERSION}" >/dev/null; \ test "$(grep -Ec '^WEB-INF/lib/freemarker-2\.3\.35\.jar$' "${listing}")" -eq 1; \ ! grep -Eq '^WEB-INF/lib/freemarker-2\.3\.(3[0-4]|[0-2][0-9])\.jar$' "${listing}"; \ test "$(grep -Ec '^WEB-INF/lib/hazelcast-5\.7\.5\.jar$' "${listing}")" -eq 1; \ @@ -594,14 +744,14 @@ RUN set -eux; \ test -d "${new_web_root}/WEB-INF/lib"; \ fi; \ resources_jar="$(find "${new_web_root}/WEB-INF/lib" -maxdepth 1 -type f \ - -name 'cattle-resources-0.183.333.jar' -print -quit)"; \ + -name "cattle-resources-${ORCHESTRATION_ENGINE_VERSION}.jar" -print -quit)"; \ test -n "${resources_jar}"; \ unzip -p "${resources_jar}" schema/user/user-auth.json | \ grep -F '"volume.isNative" : "r"' >/dev/null; \ test "$(unzip -p "${resources_jar}" schema/v1/readonly.ser | sha256sum | cut -d' ' -f1)" = \ - '7f274219e8dd9c6d750a408a6edec1448b564f4b16ba2204c425c5b45cec2233'; \ + "${ENGINE_READONLY_SCHEMA_SHA256}"; \ test "$(unzip -p "${resources_jar}" schema/v1/restricted.ser | sha256sum | cut -d' ' -f1)" = \ - 'f854ba99260f29e324ab7446a8592996946038920ac9bcf17689d98d1ebc5e51'; \ + "${ENGINE_RESTRICTED_SCHEMA_SHA256}"; \ unzip -p "${resources_jar}" schema/base/mfaOperation.json | \ grep -F 'oidcAccessPolicyUpdate' >/dev/null; \ unzip -p "${resources_jar}" schema/base/mfaOperation.json | \ @@ -619,7 +769,7 @@ RUN set -eux; \ grep -F "\"${oidc_type}\"" >/dev/null; \ done; \ app_config_jar="$(find "${new_web_root}/WEB-INF/lib" -maxdepth 1 -type f \ - -name 'cattle-app-config-0.183.333.jar' -print -quit)"; \ + -name "cattle-app-config-${ORCHESTRATION_ENGINE_VERSION}.jar" -print -quit)"; \ test -n "${app_config_jar}"; \ unzip -p "${app_config_jar}" META-INF/cattle/iaas-api/defaults.properties | \ grep -Fx 'auth.service.external.id.types=github_user,github_org,github_team,shibboleth_user,shibboleth_group,ldap_user,ldap_group,oidc_user,oidc_group' >/dev/null; \ @@ -792,6 +942,30 @@ RUN set -eux; \ grep -F "\"resourceLoadError.${load_key}\":" \ "${web_root}/translations/zh-tw.json" >/dev/null; \ done; \ + echo "${HOST_PROVISIONER_BINARY_SHA256} /usr/bin/host-provisioner.real" | sha256sum -c -; \ + /usr/bin/host-provisioner.real -v | grep -F "${HOST_PROVISIONER_VERSION}" >/dev/null; \ + test -s /usr/share/licenses/pasturestack/host-provisioner/LICENSE; \ + test -s /usr/share/licenses/pasturestack/host-provisioner/ORIGIN.md; \ + test "$(find /usr/share/licenses/pasturestack/host-provisioner/licenses -type f | wc -l)" -eq 35; \ + echo "${SECRET_DELIVERY_API_BINARY_SHA256} /usr/bin/secret-delivery-api" | sha256sum -c -; \ + /usr/bin/secret-delivery-api --version | grep -F "v${SECRET_DELIVERY_API_VERSION}" >/dev/null; \ + ln -sfn secret-delivery-api /usr/bin/secrets-api; \ + test "$(readlink -f /usr/bin/secrets-api)" = /usr/bin/secret-delivery-api; \ + echo "${USAGE_TELEMETRY_AGENT_BINARY_SHA256} /usr/bin/usage-telemetry-agent" | sha256sum -c -; \ + /usr/bin/usage-telemetry-agent --version | grep -F "usage-telemetry-agent ${USAGE_TELEMETRY_AGENT_VERSION} (" >/dev/null; \ + ln -sfn usage-telemetry-agent /usr/bin/telemetry; \ + test "$(readlink -f /usr/bin/telemetry)" = /usr/bin/usage-telemetry-agent; \ + for component in host-provisioner secret-delivery-api usage-telemetry-agent; do \ + license_dir="/usr/share/licenses/pasturestack/${component}"; \ + test ! -e "${license_dir}/${component}"; \ + grep -Fx 'Go compiler: 1.27.2' "${license_dir}/SERVER-PRODUCER-SOURCES.txt" >/dev/null; \ + if test "${component}" != host-provisioner; then \ + for suffix in LICENSES.txt SOURCES.txt THIRD-PARTY-NOTICES.md; do \ + test -s "${license_dir}/${component}-${suffix}"; \ + done; \ + fi; \ + done; \ + test -s /usr/share/licenses/pasturestack/usage-telemetry-agent/usage-telemetry-agent-PRIVACY.md; \ echo "${AUTHENTICATION_SERVICE_BINARY_SHA256} /usr/bin/authentication-service.real" | sha256sum -c -; \ echo "${CATALOG_SERVICE_BINARY_SHA256} /usr/bin/catalog-service.real" | sha256sum -c -; \ echo "${CATALOG_SERVICE_SQLITE_BINARY_SHA256} /usr/bin/catalog-service-sqlite" | sha256sum -c -; \ @@ -828,18 +1002,29 @@ RUN set -eux; \ test -s /usr/share/licenses/pasturestack/vsphere-cli-bundle/vsphere-cli-bundle-LICENSES.txt; \ test -s /usr/share/licenses/pasturestack/vsphere-cli-bundle/vsphere-cli-bundle-THIRD-PARTY-NOTICES.txt; \ grep -Fx 'Pinned source commit: a668d9c60399552ea96782b8751c956720a0b8fb' \ + /usr/share/licenses/pasturestack/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt >/dev/null; \ + grep -Fx 'Security dependency: golang.org/x/text v0.41.0' \ /usr/share/licenses/pasturestack/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt >/dev/null LABEL org.opencontainers.image.source="https://github.com/PastureStack/server" \ org.opencontainers.image.description="PastureStack server image with pure numeric dependency releases and preserved reviewed runtime behavior." \ org.opencontainers.image.licenses="Apache-2.0" \ - org.opencontainers.image.version="v1.6.518" \ + org.opencontainers.image.version="${SERVER_RELEASE_TAG}" \ org.opencontainers.image.revision="${PASTURESTACK_SERVER_REVISION}" \ org.opencontainers.image.base.name="ghcr.io/pasturestack/server:v1.6.460" \ org.opencontainers.image.base.digest="sha256:c855af8aea232dacc5bb6df68e2271d482c68b53c43ab0c108ec19118f5ab403" -ENV CATTLE_RANCHER_SERVER_VERSION=v1.6.518 -ENV CATTLE_CATTLE_VERSION=v0.183.333 +ENV CATTLE_RANCHER_SERVER_VERSION=${SERVER_RELEASE_TAG} +ENV CATTLE_CATTLE_VERSION=${ORCHESTRATION_ENGINE_RELEASE_TAG} +ENV RC16_HOST_API_VERSION=${HOST_API_VERSION} +ENV PASTURESTACK_HOST_API_PACKAGE_ID=${HOST_API_PACKAGE_ID} +ENV PASTURESTACK_HOST_API_COMMIT=${HOST_API_COMMIT} +ENV PASTURESTACK_HOST_API_ARCHIVE_SHA256=${HOST_API_ARCHIVE_SHA256} +ENV RC16_GO_AGENT_VERSION=${NODE_AGENT_VERSION} +ENV RC16_AGENT_PACKAGE_URL=/usr/share/cattle/artifacts/node-agent-${NODE_AGENT_VERSION}.tar.gz +ENV PASTURESTACK_NODE_AGENT_VERSION=${NODE_AGENT_VERSION} +ENV PASTURESTACK_NODE_AGENT_COMMIT=${NODE_AGENT_COMMIT} +ENV PASTURESTACK_NODE_AGENT_ARCHIVE_SHA256=${NODE_AGENT_ARCHIVE_SHA256} ENV PASTURESTACK_UBUNTU_SECURITY_REFRESH=2026-09-10 ENV PASTURESTACK_CURL_SECURITY_SNAPSHOT=${UBUNTU_RUNTIME_SECURITY_SNAPSHOT} ENV PASTURESTACK_CURL_PACKAGE_VERSION=${CURL_PACKAGE_VERSION} @@ -851,6 +1036,7 @@ ENV PASTURESTACK_GLIBC_PACKAGE_VERSION=${GLIBC_PACKAGE_VERSION} ENV PASTURESTACK_PERL_PACKAGE_VERSION=${PERL_PACKAGE_VERSION} ENV PASTURESTACK_LIBDBI_PERL_PACKAGE_VERSION=${LIBDBI_PERL_PACKAGE_VERSION} ENV PASTURESTACK_FREETYPE_PACKAGE_VERSION=${FREETYPE_PACKAGE_VERSION} +ENV PASTURESTACK_LIBPNG_PACKAGE_VERSION=${LIBPNG_PACKAGE_VERSION} ENV PASTURESTACK_ORCHESTRATION_ENGINE_COMMIT=${ORCHESTRATION_ENGINE_COMMIT} ENV PASTURESTACK_ORCHESTRATION_ENGINE_ARTIFACT_SHA256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256} ENV PASTURESTACK_WEB_CONSOLE_PACKAGE=${WEB_CONSOLE_RELEASE_TAG} @@ -865,6 +1051,20 @@ ENV PASTURESTACK_AUTHENTICATION_SERVICE_VERSION=${AUTHENTICATION_SERVICE_VERSION ENV PASTURESTACK_AUTHENTICATION_SERVICE_COMMIT=${AUTHENTICATION_SERVICE_COMMIT} ENV PASTURESTACK_AUTHENTICATION_SERVICE_ARCHIVE_SHA256=${AUTHENTICATION_SERVICE_ARCHIVE_SHA256} ENV PASTURESTACK_AUTHENTICATION_SERVICE_BINARY_SHA256=${AUTHENTICATION_SERVICE_BINARY_SHA256} +ENV PASTURESTACK_RUNTIME_GO_VERSION=1.27.2 +ENV PASTURESTACK_CONSOLE_BROKER_GO_VERSION=1.27.2 +ENV PASTURESTACK_HOST_PROVISIONER_VERSION=${HOST_PROVISIONER_VERSION} +ENV PASTURESTACK_HOST_PROVISIONER_COMMIT=${HOST_PROVISIONER_COMMIT} +ENV PASTURESTACK_HOST_PROVISIONER_ARCHIVE_SHA256=${HOST_PROVISIONER_ARCHIVE_SHA256} +ENV PASTURESTACK_HOST_PROVISIONER_BINARY_SHA256=${HOST_PROVISIONER_BINARY_SHA256} +ENV PASTURESTACK_SECRET_DELIVERY_API_VERSION=${SECRET_DELIVERY_API_VERSION} +ENV PASTURESTACK_SECRET_DELIVERY_API_COMMIT=${SECRET_DELIVERY_API_COMMIT} +ENV PASTURESTACK_SECRET_DELIVERY_API_ARCHIVE_SHA256=${SECRET_DELIVERY_API_ARCHIVE_SHA256} +ENV PASTURESTACK_SECRET_DELIVERY_API_BINARY_SHA256=${SECRET_DELIVERY_API_BINARY_SHA256} +ENV PASTURESTACK_USAGE_TELEMETRY_AGENT_VERSION=${USAGE_TELEMETRY_AGENT_VERSION} +ENV PASTURESTACK_USAGE_TELEMETRY_AGENT_COMMIT=${USAGE_TELEMETRY_AGENT_COMMIT} +ENV PASTURESTACK_USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256=${USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256} +ENV PASTURESTACK_USAGE_TELEMETRY_AGENT_BINARY_SHA256=${USAGE_TELEMETRY_AGENT_BINARY_SHA256} ENV PASTURESTACK_WEBSOCKET_PROXY_VERSION=${WEBSOCKET_PROXY_VERSION} ENV PASTURESTACK_WEBSOCKET_PROXY_COMMIT=${WEBSOCKET_PROXY_COMMIT} ENV PASTURESTACK_WEBSOCKET_PROXY_ARCHIVE_SHA256=${WEBSOCKET_PROXY_ARCHIVE_SHA256} diff --git a/server/artifacts/component-input.txt b/server/artifacts/component-input.txt new file mode 100644 index 00000000000..de0ab516410 --- /dev/null +++ b/server/artifacts/component-input.txt @@ -0,0 +1,2 @@ +Default remote component source. Local builds override the component_input +BuildKit context with a directory containing exactly the five verified assets. diff --git a/server/artifacts/fetch-server-component.sh b/server/artifacts/fetch-server-component.sh new file mode 100755 index 00000000000..fb24e2a9a36 --- /dev/null +++ b/server/artifacts/fetch-server-component.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ $# == 5 ]] || { echo 'usage: fetch-server-component MODE URL NAME SHA256 OUTPUT' >&2; exit 2; } +mode=$1 url=$2 name=$3 expected=$4 output=$5 +[[ "$name" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]] +[[ "$expected" =~ ^[0-9a-f]{64}$ ]] +case "$mode" in + local) + input="/tmp/pasturestack-component-input/$name" + test -f "$input" && test ! -L "$input" + cp -- "$input" "$output" + ;; + remote) + case "$url" in + https://*) ;; + http://127.0.0.1:*|http://localhost:*) [[ ${PASTURESTACK_ALLOW_LOOPBACK_ARTIFACTS:-0} == 1 ]] ;; + *) echo 'Component source must use HTTPS' >&2; exit 1 ;; + esac + curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ + --connect-timeout 10 --max-time 300 -o "$output" "$url" + ;; + *) echo 'Unknown component artifact mode' >&2; exit 2 ;; +esac +printf '%s %s\n' "$expected" "$output" | sha256sum -c - diff --git a/server/artifacts/verify-agent-package.sh b/server/artifacts/verify-agent-package.sh new file mode 100755 index 00000000000..8ad9af0f302 --- /dev/null +++ b/server/artifacts/verify-agent-package.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ $# == 5 || $# == 6 ]] || { echo 'usage: verify-agent-package COMPONENT ARCHIVE PACKAGE_ID BINARY_SHA256 APPLY_SHA256 [SOURCE_COMMIT]' >&2; exit 2; } +component=$1 archive=$(realpath -- "$2") root=$3 binary_hash=$4 apply_hash=$5 +[[ "$component" == host-api || "$component" == node-agent ]] +[[ "$root" =~ ^[0-9a-f]{32}$ ]] +[[ "$binary_hash" =~ ^[0-9a-f]{64}$ && "$apply_hash" =~ ^[0-9a-f]{64}$ ]] +test -s "$archive" && test ! -L "$archive" +scratch=$(mktemp -d) +trap 'rm -rf -- "$scratch"' EXIT +tar -tzf "$archive" >"$scratch/listing" +! grep -Eq '(^/|(^|/)\.\.(/|$))' "$scratch/listing" +test -z "$(LC_ALL=C sort "$scratch/listing" | uniq -d)" +tar -tvzf "$archive" >"$scratch/verbose" +! grep -Eq '^[lh]' "$scratch/verbose" +while IFS= read -r member; do + case "$member" in "$root/"|"$root/"*) ;; *) echo 'Unexpected agent package root' >&2; exit 1 ;; esac +done <"$scratch/listing" +mkdir "$scratch/content" +tar --no-same-owner --no-same-permissions -xzf "$archive" -C "$scratch/content" +cd "$scratch/content" +for manifest in SHA1SUMS SHA1SUMSSUM SHA256SUMS SHA256SUMSSUM; do + test -s "$root/$manifest" + awk -v root="$root/" '{ path=$0; if (path !~ /^[0-9a-f]+ [ *]/) exit 1; sub(/^[0-9a-f]+ [ *]/, "", path); if (index(path, root) != 1 || path ~ /(^|\/)\.\.(\/|$)/) exit 1 }' "$root/$manifest" +done +sha1sum -c "$root/SHA1SUMSSUM" +sha1sum -c "$root/SHA1SUMS" +sha256sum -c "$root/SHA256SUMSSUM" +sha256sum -c "$root/SHA256SUMS" +printf '%s %s\n' "$binary_hash" "$root/bin/$component" "$apply_hash" "$root/apply.sh" | sha256sum -c - +if [[ -n ${6:-} ]]; then + [[ "$6" =~ ^[0-9a-f]{40}$ ]] + go version -m "$root/bin/$component" | grep -F "vcs.revision=$6" >/dev/null + go version -m "$root/bin/$component" | grep -F 'vcs.modified=false' >/dev/null +fi +printf 'AGENT_PRODUCER_PACKAGE_OK component=%s package_id=%s\n' "$component" "$root" diff --git a/server/artifacts/verify-host-api-package.sh b/server/artifacts/verify-host-api-package.sh new file mode 100755 index 00000000000..54cf2ffd12f --- /dev/null +++ b/server/artifacts/verify-host-api-package.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ $# == 4 || $# == 5 ]] || { echo 'usage: verify-host-api-package ARCHIVE PACKAGE_ID BINARY_SHA256 APPLY_SHA256 [SOURCE_COMMIT]' >&2; exit 2; } +bash "$(dirname "${BASH_SOURCE[0]}")/verify-agent-package.sh" host-api "$@" +printf 'HOST_API_PRODUCER_PACKAGE_OK package_id=%s\n' "$2" diff --git a/server/artifacts/verify-runtime-producer.sh b/server/artifacts/verify-runtime-producer.sh new file mode 100644 index 00000000000..ef04635fe47 --- /dev/null +++ b/server/artifacts/verify-runtime-producer.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Build-time validation of the original Host/Secret/Telemetry producer assets. +set -euo pipefail +[[ $# == 6 ]] || { echo 'usage: verify-runtime-producer TYPE VERSION COMMIT ARCHIVE BINARY_SHA256 OUTPUT' >&2; exit 2; } +type=$1 version=$2 commit=$3 archive=$4 binary_sha=$5 output=$6 +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$commit" =~ ^[0-9a-f]{40}$ && "$binary_sha" =~ ^[0-9a-f]{64}$ ]] +[[ "$output" == "/out/$type" && ! -e "$output" ]] +listing=$(mktemp /tmp/runtime-producer-list.XXXXXXXX) +expected=$(mktemp /tmp/runtime-producer-expected.XXXXXXXX) +verbose=$(mktemp /tmp/runtime-producer-verbose.XXXXXXXX) +duplicates=$(mktemp /tmp/runtime-producer-duplicates.XXXXXXXX) +trap 'rm -f -- "$listing" "$expected" "$verbose" "$duplicates"' EXIT +tar -tJf "$archive" > "$listing" +! grep -Eq '(^/|(^|/)\.\.(/|$))' "$listing" +sort "$listing" | uniq -d > "$duplicates" +test ! -s "$duplicates" +tar -tvJf "$archive" > "$verbose" +! grep -Eq '^[lh]' "$verbose" +case "$type" in + host-provisioner) + package_root="host-provisioner-${version}-linux-amd64" + while IFS= read -r member; do + case "$member" in "$package_root/"|"$package_root/"*) ;; *) exit 1 ;; esac + done < "$listing" + mkdir -p "$output" + tar --no-same-owner --no-same-permissions --strip-components=1 -xJf "$archive" -C "$output" + test -s "$output/LICENSE" && test -s "$output/ORIGIN.md" + test "$(find "$output/licenses" -type f | wc -l)" -eq 35 + version_flag=-v + ;; + secret-delivery-api|usage-telemetry-agent) + printf '%s\n' "$type" "$type-LICENSES.txt" "$type-SOURCES.txt" "$type-THIRD-PARTY-NOTICES.md" > "$expected" + if [[ "$type" == usage-telemetry-agent ]]; then printf '%s\n' "$type-PRIVACY.md" >> "$expected"; fi + sort "$listing" | cmp - <(sort "$expected") + mkdir -p "$output" + tar --no-same-owner --no-same-permissions -xJf "$archive" -C "$output" + grep -Fx "Release source commit: $commit" "$output/$type-SOURCES.txt" >/dev/null + version_flag=--version + ;; + *) echo 'Unknown runtime producer' >&2; exit 2 ;; +esac +test -f "$output/$type" && test ! -L "$output/$type" +echo "$binary_sha $output/$type" | sha256sum -c - +chmod 0755 "$output/$type" +"$output/$type" "$version_flag" | grep -F "$version" >/dev/null +metadata=$(go version -m "$output/$type") +test "$(printf '%s\n' "$metadata" | awk 'NR==1 {print $2}')" = go1.27.2 +for field in 'CGO_ENABLED=0' 'GOOS=linux' 'GOARCH=amd64' 'GOAMD64=v1'; do + printf '%s\n' "$metadata" | grep -F "$field" >/dev/null +done +printf '%s\n' "Release source commit: $commit" "Binary SHA-256: $binary_sha" "Go compiler: 1.27.2" > "$output/SERVER-PRODUCER-SOURCES.txt" +mkdir -p "/out/$type-licenses" +for entry in "$output"/*; do + if [[ "${entry##*/}" != "$type" ]]; then mv -- "$entry" "/out/$type-licenses/"; fi +done diff --git a/server/build-api-explorer-patch-image.sh b/server/build-api-explorer-patch-image.sh index b8fa4c9594f..84b690d98d6 100755 --- a/server/build-api-explorer-patch-image.sh +++ b/server/build-api-explorer-patch-image.sh @@ -14,59 +14,112 @@ if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then fi revision=${PASTURESTACK_SERVER_REVISION:-$(git rev-parse HEAD)} +server_release_tag=${SERVER_RELEASE_TAG:-v1.6.519} source_date_epoch=${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)} base_image=${BASE_IMAGE:-ghcr.io/pasturestack/server:v1.6.460@sha256:c855af8aea232dacc5bb6df68e2271d482c68b53c43ab0c108ec19118f5ab403} orchestration_engine_release_base_url=${ORCHESTRATION_ENGINE_RELEASE_BASE_URL:-https://github.com/PastureStack/orchestration-engine/releases/download} -orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.333} +orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.335} orchestration_engine_artifact=${ORCHESTRATION_ENGINE_ARTIFACT:-cattle.jar} -orchestration_engine_artifact_sha256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256:-8c42c0982cbc2f4569fa265ad320b341551758cb4fc0bc6d79ba06d70e20d328} -orchestration_engine_commit=${ORCHESTRATION_ENGINE_COMMIT:-0d94f7d879d314235e582a7f4062914a27b82709} +orchestration_engine_artifact_sha256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256:-98a665f59bced8f7c084e786280100e0571390b04eeffad140e633b463d8a204} +orchestration_engine_commit=${ORCHESTRATION_ENGINE_COMMIT:-8fd5e7d3021507647064da1ae9c9c3a6eeb4c725} +orchestration_engine_version=${orchestration_engine_release_tag#v} +engine_readonly_schema_sha256=${ENGINE_READONLY_SCHEMA_SHA256:-7f274219e8dd9c6d750a408a6edec1448b564f4b16ba2204c425c5b45cec2233} +engine_restricted_schema_sha256=${ENGINE_RESTRICTED_SCHEMA_SHA256:-f854ba99260f29e324ab7446a8592996946038920ac9bcf17689d98d1ebc5e51} +host_api_version=${HOST_API_VERSION:-0.38.5} +host_api_package_mode=${HOST_API_PACKAGE_MODE:-producer} +host_api_release_base_url=${HOST_API_RELEASE_BASE_URL:-https://github.com/PastureStack/host-api/releases/download} +host_api_release_tag=${HOST_API_RELEASE_TAG:-v0.38.5} +host_api_archive_sha256=${HOST_API_ARCHIVE_SHA256:-ba111038695aa03a82c51d60944bbbdbbec30bb269051ba29fa21d46ada17179} +host_api_package_id=${HOST_API_PACKAGE_ID:-84754fe8bc79027d0c72f492923c4786} +host_api_binary_sha256=${HOST_API_BINARY_SHA256:-fda5080d6cfec0a4c1e9daf15b43cea2dce7ffb116c8292ff0f3547c8fb371f7} +host_api_apply_sha256=${HOST_API_APPLY_SHA256:-8a21f63099832afb571755011bbcf8d97710994a50c419b20700cbc31efced0f} +host_api_commit=${HOST_API_COMMIT:-84754fe8bc79027d0c72f492923c47861aa74b99} +node_agent_version=${NODE_AGENT_VERSION:-0.13.28} +node_agent_release_base_url=${NODE_AGENT_RELEASE_BASE_URL:-https://github.com/PastureStack/node-agent/releases/download} +node_agent_commit=${NODE_AGENT_COMMIT:-bb4e057c089e5384bc9836b42db206326c690387} +node_agent_package_id=${NODE_AGENT_PACKAGE_ID:-${node_agent_commit:0:32}} +node_agent_archive_sha256=${NODE_AGENT_ARCHIVE_SHA256:-a16ae6be5fb72fa2ba38bd1076fd3b9e0536c48e70995ffa49d17b8063e2f8d0} +node_agent_binary_sha256=${NODE_AGENT_BINARY_SHA256:-ce8342e10da07c50cc414b471cadc67db4dd390e6e2dcb632b30c542f658b021} +node_agent_apply_sha256=${NODE_AGENT_APPLY_SHA256:-dd8cb342518a43e7468cc121db5c4e44b16731f2620b77ea0fd03c02c03766a9} +component_artifact_mode=remote api_explorer_release_base_url=${API_EXPLORER_RELEASE_BASE_URL:-https://github.com/PastureStack/api-explorer/releases/download} api_explorer_release_tag=${API_EXPLORER_RELEASE_TAG:-v1.1.18} api_explorer_artifact=${API_EXPLORER_ARTIFACT:-api-explorer-1.1.18.tar.gz} api_explorer_artifact_sha256=${API_EXPLORER_ARTIFACT_SHA256:-92b718c46163018ea40c008ac552911f0eb610647377725405f4046dcd411f2c} api_explorer_commit=${API_EXPLORER_COMMIT:-3b1c39e8a116f58649d94233a384a0362c02b43e} web_console_release_base_url=${WEB_CONSOLE_RELEASE_BASE_URL:-https://github.com/PastureStack/web-console/releases/download} -web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.180} -web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.180.tar.gz} -web_console_artifact_sha256=${WEB_CONSOLE_ARTIFACT_SHA256:-a367bd6907281298a8e2bf0f3ad0444083db06062f3a1521db573cc5606d274e} -web_console_commit=${WEB_CONSOLE_COMMIT:-637604b38401b19d2c9ef73d5729d356bb80c8e6} +web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.181} +web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.181.tar.gz} +web_console_artifact_sha256=${WEB_CONSOLE_ARTIFACT_SHA256:-9199681f2c4311f3fbe8727fcf0f6fa136c9bafe9fbe9d13878c55df442a7a03} +web_console_commit=${WEB_CONSOLE_COMMIT:-7f2bdad0c90a3241693c2ddd4475202ababba550} catalog_service_release_base_url=${CATALOG_SERVICE_RELEASE_BASE_URL:-https://github.com/PastureStack/catalog-service/releases/download} -catalog_service_version=${CATALOG_SERVICE_VERSION:-0.20.12} -catalog_service_commit=${CATALOG_SERVICE_COMMIT:-d708579092eae0fd03b2750ac594ff0396cf563b} -catalog_service_archive_sha256=${CATALOG_SERVICE_ARCHIVE_SHA256:-34b76c121270c603501664f7146d41916c7f45da983e326861ed4c5b9614372d} -catalog_service_binary_sha256=${CATALOG_SERVICE_BINARY_SHA256:-3deb43f9760d7cbb07f818dd108ab35abf9efc6908d5d7fcaf5c44a185f2fba9} -catalog_service_sqlite_binary_sha256=${CATALOG_SERVICE_SQLITE_BINARY_SHA256:-8805af3c0b5968a02f994d65de715c525b73637aa2dc398a0648feeeaf2fd397} +catalog_service_version=${CATALOG_SERVICE_VERSION:-0.20.13} +catalog_service_commit=${CATALOG_SERVICE_COMMIT:-4c39c73a8131ba06e9ff0aaec3b95cc27e049324} +catalog_service_archive_sha256=${CATALOG_SERVICE_ARCHIVE_SHA256:-29626181cb8489b016e5975ffaddc00b2a32d290b1c0066e833d27fa7a5edf83} +catalog_service_binary_sha256=${CATALOG_SERVICE_BINARY_SHA256:-7224c76e5643130dee047e3f1888ce6845d024307bec74d72eebdc4baeae1aea} +catalog_service_sqlite_binary_sha256=${CATALOG_SERVICE_SQLITE_BINARY_SHA256:-4aeda3e1ee1ca4c57f26938f3ef27651f9c8c129ee12957642960d22b95034ce} catalog_service_license_sha256=${CATALOG_SERVICE_LICENSE_SHA256:-0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594} authentication_service_release_base_url=${AUTHENTICATION_SERVICE_RELEASE_BASE_URL:-https://github.com/PastureStack/authentication-service/releases/download} -authentication_service_version=${AUTHENTICATION_SERVICE_VERSION:-0.4.42} -authentication_service_commit=${AUTHENTICATION_SERVICE_COMMIT:-5589ef8fda68ae56e1afd64096965d452ee8a17e} -authentication_service_archive_sha256=${AUTHENTICATION_SERVICE_ARCHIVE_SHA256:-f14d22036a0a88d6a8d669700506bba680fc7605bbca2b337e345c5cd71500fb} -authentication_service_binary_sha256=${AUTHENTICATION_SERVICE_BINARY_SHA256:-feaabe4bba85cbe119c98a79a27abb4510401fc051f34d02aa7b48d69bdbe746} +authentication_service_version=${AUTHENTICATION_SERVICE_VERSION:-0.4.43} +authentication_service_commit=${AUTHENTICATION_SERVICE_COMMIT:-cae736f377019bd9743648a8e9aa469a0e21b5e0} +authentication_service_archive_sha256=${AUTHENTICATION_SERVICE_ARCHIVE_SHA256:-e9218771af8dd68323c8c6fdab149c40a3ad02da9ff23f8aad6f0b2977740273} +authentication_service_binary_sha256=${AUTHENTICATION_SERVICE_BINARY_SHA256:-fe11eec4b31b43863b49a582b1dbbe309eae08fb78adc150037981174f0622da} websocket_proxy_release_base_url=${WEBSOCKET_PROXY_RELEASE_BASE_URL:-https://github.com/PastureStack/websocket-proxy/releases/download} -websocket_proxy_version=${WEBSOCKET_PROXY_VERSION:-0.23.14} -websocket_proxy_commit=${WEBSOCKET_PROXY_COMMIT:-3b5788bdc52f4edab0097a3d97afccf138c64089} -websocket_proxy_archive_sha256=${WEBSOCKET_PROXY_ARCHIVE_SHA256:-c55108c3dbfd8e6579fc768a1988920db83c6f605ca1284b60edf92ae8d0160e} -websocket_proxy_binary_sha256=${WEBSOCKET_PROXY_BINARY_SHA256:-efd0c78779a620b4b0f74a10eb3f3edd8886e8d23f22dc4624d8e9971085a26d} +websocket_proxy_version=${WEBSOCKET_PROXY_VERSION:-0.23.15} +websocket_proxy_commit=${WEBSOCKET_PROXY_COMMIT:-1928f602b66443cdab40c8cdb450c811548d2749} +websocket_proxy_archive_sha256=${WEBSOCKET_PROXY_ARCHIVE_SHA256:-4657338973f672f6ae4d6e5510d06e811b9951afea1baa27a9caa3034e487f2a} +websocket_proxy_binary_sha256=${WEBSOCKET_PROXY_BINARY_SHA256:-9111d5a569b6326d7cd71fc3384251d9684972492a22e1e9dbbb9863019fbaaa} webhook_automation_service_release_base_url=${WEBHOOK_AUTOMATION_SERVICE_RELEASE_BASE_URL:-https://github.com/PastureStack/webhook-automation-service/releases/download} -webhook_automation_service_version=${WEBHOOK_AUTOMATION_SERVICE_VERSION:-0.10.3} -webhook_automation_service_commit=${WEBHOOK_AUTOMATION_SERVICE_COMMIT:-fbcc0ca07e42e9b21bda18031d0848192ec2f9a1} -webhook_automation_service_archive_sha256=${WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256:-6babbc18cee9a192009cfadcd143e6b9a5f2b550c4dc419781f3e3657caa022a} -webhook_automation_service_binary_sha256=${WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256:-9094f3b2527762a3e683b02d93aa00e52618e902cd409e72e34553d98d98a609} -compose_executor_version=${COMPOSE_EXECUTOR_VERSION:-0.14.36} -compose_executor_commit=${COMPOSE_EXECUTOR_COMMIT:-e85545a1bc34cb5c42db62ff90dd82b7ff9f5838} -compose_executor_archive_sha256=${COMPOSE_EXECUTOR_ARCHIVE_SHA256:-47e2ba1686c1b136c7edcac530495c3e29c351e947f0b4d08ebe68d33f98cf66} -compose_executor_binary_sha256=${COMPOSE_EXECUTOR_BINARY_SHA256:-1f542ee2dd76c7af06bc5f056c381d7e77aecaeac40f8d897df6df24a9902c0d} -vsphere_cli_bundle_version=${VSPHERE_CLI_BUNDLE_VERSION:-0.55.2} -vsphere_cli_bundle_commit=${VSPHERE_CLI_BUNDLE_COMMIT:-c4b27e87aa0dacce432a2c6108ee0752319e6d5b} -vsphere_cli_bundle_archive_sha256=${VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256:-bebcc1c0275072ac40b5bc9b80f914c40a7f0431fffebc2c06fe34a34c33a57c} -govc_binary_sha256=${GOVC_BINARY_SHA256:-f8c7d82a614655c83ee119e3f170a302a9b35d9ca7efd13bbc226df2d68e5d31} +webhook_automation_service_version=${WEBHOOK_AUTOMATION_SERVICE_VERSION:-0.10.4} +webhook_automation_service_commit=${WEBHOOK_AUTOMATION_SERVICE_COMMIT:-400118b893843d2a7d7c65cc70c3449d76c4a8d8} +webhook_automation_service_archive_sha256=${WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256:-49c4579829a04e758045fae02a5a9fca12bb0ba3af0d5e979cf9eb97f23a88a9} +webhook_automation_service_binary_sha256=${WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256:-98c7faea665b7eb95206b8c73a6f47d644c5d2d0eae53f274f6a15faf5205744} +compose_executor_release_base_url=${COMPOSE_EXECUTOR_RELEASE_BASE_URL:-https://github.com/PastureStack/compose-cli/releases/download} +compose_executor_version=${COMPOSE_EXECUTOR_VERSION:-0.14.37} +compose_executor_commit=${COMPOSE_EXECUTOR_COMMIT:-88e991e823f06d2334c07d5370595aae3c48ee99} +compose_executor_archive_sha256=${COMPOSE_EXECUTOR_ARCHIVE_SHA256:-5ff465601930218f531885a384f231d969033e4cef794ce33d16bfe24e5c1c81} +compose_executor_binary_sha256=${COMPOSE_EXECUTOR_BINARY_SHA256:-a9bf9f0f77e914fe557d3e178a73c31526b0ca0adc4afbf17bf68d8d75c7ee27} +vsphere_cli_bundle_release_base_url=${VSPHERE_CLI_BUNDLE_RELEASE_BASE_URL:-https://github.com/PastureStack/vsphere-cli-bundle/releases/download} +vsphere_cli_bundle_version=${VSPHERE_CLI_BUNDLE_VERSION:-0.55.3} +vsphere_cli_bundle_commit=${VSPHERE_CLI_BUNDLE_COMMIT:-f48ab9fd9990132c85845fc162186a04f1e0418d} +vsphere_cli_bundle_archive_sha256=${VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256:-31be702e515741686e2c665d387562e5e993c5d2ffbdb2d614ed287243b166e4} +govc_binary_sha256=${GOVC_BINARY_SHA256:-d3c4f4fab44403ec4110743b52da99f5ce3d7e3773c4661db8a87dec3ead8990} +host_provisioner_release_base_url=${HOST_PROVISIONER_RELEASE_BASE_URL:-https://github.com/PastureStack/host-provisioner/releases/download} +host_provisioner_version=${HOST_PROVISIONER_VERSION:-0.39.8} +host_provisioner_commit=${HOST_PROVISIONER_COMMIT:-385e5b536c108f17fdfcdec84c01750a5be5ab1c} +host_provisioner_archive_sha256=${HOST_PROVISIONER_ARCHIVE_SHA256:-d775f36a613b1a486a5e60d6ad61fdbd1ebb4bd22422cbb6dcb70fdd7c4abf0c} +host_provisioner_binary_sha256=${HOST_PROVISIONER_BINARY_SHA256:-1d37e20a7a1cf4f3e36036a15ff7699ef22cd8809fd14a20892034dd054fd1fc} +secret_delivery_api_release_base_url=${SECRET_DELIVERY_API_RELEASE_BASE_URL:-https://github.com/PastureStack/secret-delivery-api/releases/download} +secret_delivery_api_version=${SECRET_DELIVERY_API_VERSION:-0.3.2} +secret_delivery_api_commit=${SECRET_DELIVERY_API_COMMIT:-f9c3f933f14e43dc63074d45a3044b693ec1e573} +secret_delivery_api_archive_sha256=${SECRET_DELIVERY_API_ARCHIVE_SHA256:-ca9ab0bfddbcad84b6c1a865af8ad82fcc56cb43dcca1da240ed11a5d24cbdb2} +secret_delivery_api_binary_sha256=${SECRET_DELIVERY_API_BINARY_SHA256:-b5e01b7e65aeee3b456fe043142b5e70f4039ee89ffd437648368ff63e6042b6} +usage_telemetry_agent_release_base_url=${USAGE_TELEMETRY_AGENT_RELEASE_BASE_URL:-https://github.com/PastureStack/usage-telemetry-agent/releases/download} +usage_telemetry_agent_version=${USAGE_TELEMETRY_AGENT_VERSION:-0.4.2} +usage_telemetry_agent_commit=${USAGE_TELEMETRY_AGENT_COMMIT:-40f9af7ca932fedacdb87e30b4ef1c60a4a7444e} +usage_telemetry_agent_archive_sha256=${USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256:-5ce031c84f76b3e62dafdb04fb4ed014aa1921e83be056c2d5dd712acbce25a8} +usage_telemetry_agent_binary_sha256=${USAGE_TELEMETRY_AGENT_BINARY_SHA256:-e62a21270142181315293d7e11482288ffe8fc4d91cc8fe07dacc90b461f3709} supported_docker_range='~v1.12.3 || ~v1.13.0 || ~v17.03.0 || ~v17.06.0 || ~v17.09.0 || ~v17.12.0 || ~v18.03.0 || ~v18.06.0 || ~v18.09.0 || ~v19.03.2 || v24.0.9 || >=v29.4.1 <=v29.7.2 || v29.8.0' newest_docker_version=v29.8.0 -image=${IMAGE:-pasturestack-validation/server:v1.6.518} +image=${IMAGE:-pasturestack-validation/server:v1.6.519} build_options=() [[ "$revision" =~ ^[0-9a-f]{40}$ ]] +[[ "$server_release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] +[[ "$host_api_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +[[ "$host_api_release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] +[[ "$host_api_package_id" =~ ^[0-9a-f]{32}$ ]] +[[ "$node_agent_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +[[ "$node_agent_commit" =~ ^[0-9a-f]{40}$ ]] +[[ "$node_agent_package_id" == "${node_agent_commit:0:32}" ]] +for component_hash in "$host_api_archive_sha256" "$host_api_binary_sha256" "$host_api_apply_sha256" "$engine_readonly_schema_sha256" "$engine_restricted_schema_sha256" "$node_agent_archive_sha256" "$node_agent_binary_sha256" "$node_agent_apply_sha256"; do + [[ "$component_hash" =~ ^[0-9a-f]{64}$ ]] +done +if [[ "$host_api_package_mode" == legacy-repair ]]; then + [[ "$host_api_version" == 0.38.4 ]] +else + [[ "$host_api_package_mode" == producer && "$host_api_commit" =~ ^[0-9a-f]{40}$ ]] +fi [[ "$source_date_epoch" =~ ^[0-9]+$ ]] [[ "$orchestration_engine_commit" =~ ^[0-9a-f]{40}$ ]] [[ "$orchestration_engine_artifact_sha256" =~ ^[0-9a-f]{64}$ ]] @@ -106,8 +159,24 @@ build_options=() [[ "$vsphere_cli_bundle_commit" =~ ^[0-9a-f]{40}$ ]] [[ "$vsphere_cli_bundle_archive_sha256" =~ ^[0-9a-f]{64}$ ]] [[ "$govc_binary_sha256" =~ ^[0-9a-f]{64}$ ]] +for component_version in "$host_provisioner_version" "$secret_delivery_api_version" "$usage_telemetry_agent_version"; do + [[ "$component_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +done +for component_commit in "$host_provisioner_commit" "$secret_delivery_api_commit" "$usage_telemetry_agent_commit"; do + [[ "$component_commit" =~ ^[0-9a-f]{40}$ ]] +done +for component_hash in "$host_provisioner_archive_sha256" "$host_provisioner_binary_sha256" "$secret_delivery_api_archive_sha256" "$secret_delivery_api_binary_sha256" "$usage_telemetry_agent_archive_sha256" "$usage_telemetry_agent_binary_sha256"; do + [[ "$component_hash" =~ ^[0-9a-f]{64}$ ]] +done +for release_base_url in "$host_provisioner_release_base_url" "$secret_delivery_api_release_base_url" "$usage_telemetry_agent_release_base_url"; do + case "$release_base_url" in + https://*) ;; + http://127.0.0.1:*|http://localhost:*) [[ ${PASTURESTACK_ALLOW_LOOPBACK_ARTIFACTS:-0} == 1 ]] ;; + *) echo 'Runtime producer source must use HTTPS or authorized loopback' >&2; exit 1 ;; + esac +done [[ "$base_image" == ghcr.io/pasturestack/server:v1.6.460@sha256:c855af8aea232dacc5bb6df68e2271d482c68b53c43ab0c108ec19118f5ab403 ]] -for release_base_url in "$orchestration_engine_release_base_url" "$api_explorer_release_base_url" "$web_console_release_base_url" "$catalog_service_release_base_url" "$authentication_service_release_base_url" "$websocket_proxy_release_base_url" "$webhook_automation_service_release_base_url"; do +for release_base_url in "$orchestration_engine_release_base_url" "$api_explorer_release_base_url" "$web_console_release_base_url" "$catalog_service_release_base_url" "$authentication_service_release_base_url" "$websocket_proxy_release_base_url" "$webhook_automation_service_release_base_url" "$host_api_release_base_url" "$node_agent_release_base_url" "$compose_executor_release_base_url" "$vsphere_cli_bundle_release_base_url"; do case "$release_base_url" in https://*) ;; http://127.0.0.1:*|http://localhost:*) @@ -122,6 +191,38 @@ done if [[ ${PASTURESTACK_BUILD_NO_CACHE:-0} == 1 ]]; then build_options+=(--no-cache) fi +if [[ -n ${PASTURESTACK_COMPONENT_ARTIFACT_DIR:-} ]]; then + component_artifact_mode=local + component_directory=$(realpath -- "$PASTURESTACK_COMPONENT_ARTIFACT_DIR") + [[ "$component_directory" != / && "$component_directory" != "$repo_root" && "$component_directory" != "$server_dir" ]] + test -d "$component_directory" + test -z "$(find "$component_directory" -type l -print -quit)" + component_assets=( + "$orchestration_engine_artifact|$orchestration_engine_artifact_sha256" + "$web_console_artifact|$web_console_artifact_sha256" + "websocket-proxy-${websocket_proxy_version}-linux-amd64.tar.xz|$websocket_proxy_archive_sha256" + "host-api-${host_api_version}.tar.gz|$host_api_archive_sha256" + "node-agent-${node_agent_version}.tar.gz|$node_agent_archive_sha256" + "host-provisioner-${host_provisioner_version}-linux-amd64.tar.xz|$host_provisioner_archive_sha256" + "secret-delivery-api-${secret_delivery_api_version}-linux-amd64.tar.xz|$secret_delivery_api_archive_sha256" + "usage-telemetry-agent-${usage_telemetry_agent_version}-linux-amd64.tar.xz|$usage_telemetry_agent_archive_sha256" + "catalog-service-${catalog_service_version}.tar.xz|$catalog_service_archive_sha256" + "catalog-service-${catalog_service_version}-LICENSE.txt|$catalog_service_license_sha256" + "authentication-service-${authentication_service_version}-linux-amd64.tar.xz|$authentication_service_archive_sha256" + "webhook-automation-service-${webhook_automation_service_version}-linux-amd64.tar.xz|$webhook_automation_service_archive_sha256" + "compose-executor-${compose_executor_version}-linux-amd64.gz|$compose_executor_archive_sha256" + "vsphere-cli-bundle-${vsphere_cli_bundle_version}-linux-amd64.tar.xz|$vsphere_cli_bundle_archive_sha256" + ) + expected_components=$(printf '%s\n' "${component_assets[@]%%|*}" | LC_ALL=C sort) + actual_components=$(find "$component_directory" -mindepth 1 -printf '%P\n' | LC_ALL=C sort) + [[ "$actual_components" == "$expected_components" ]] + for component_asset in "${component_assets[@]}"; do + IFS='|' read -r component_name component_sha256 <<< "$component_asset" + test -s "$component_directory/$component_name" && test -f "$component_directory/$component_name" + printf '%s %s\n' "$component_sha256" "$component_directory/$component_name" | sha256sum -c - + done + build_options+=(--build-context "component_input=$component_directory") +fi docker buildx build \ "${build_options[@]}" \ @@ -131,6 +232,28 @@ docker buildx build \ --build-arg "BASE_IMAGE=${base_image}" \ --build-arg "SOURCE_DATE_EPOCH=${source_date_epoch}" \ --build-arg "PASTURESTACK_SERVER_REVISION=${revision}" \ + --build-arg "SERVER_RELEASE_TAG=${server_release_tag}" \ + --build-arg "ORCHESTRATION_ENGINE_VERSION=${orchestration_engine_version}" \ + --build-arg "ENGINE_READONLY_SCHEMA_SHA256=${engine_readonly_schema_sha256}" \ + --build-arg "ENGINE_RESTRICTED_SCHEMA_SHA256=${engine_restricted_schema_sha256}" \ + --build-arg "COMPONENT_ARTIFACT_MODE=${component_artifact_mode}" \ + --build-arg "PASTURESTACK_ALLOW_LOOPBACK_ARTIFACTS=${PASTURESTACK_ALLOW_LOOPBACK_ARTIFACTS:-0}" \ + --build-arg "HOST_API_VERSION=${host_api_version}" \ + --build-arg "HOST_API_PACKAGE_MODE=${host_api_package_mode}" \ + --build-arg "HOST_API_RELEASE_BASE_URL=${host_api_release_base_url}" \ + --build-arg "HOST_API_RELEASE_TAG=${host_api_release_tag}" \ + --build-arg "HOST_API_ARCHIVE_SHA256=${host_api_archive_sha256}" \ + --build-arg "HOST_API_PACKAGE_ID=${host_api_package_id}" \ + --build-arg "HOST_API_BINARY_SHA256=${host_api_binary_sha256}" \ + --build-arg "HOST_API_APPLY_SHA256=${host_api_apply_sha256}" \ + --build-arg "HOST_API_COMMIT=${host_api_commit}" \ + --build-arg "NODE_AGENT_VERSION=${node_agent_version}" \ + --build-arg "NODE_AGENT_RELEASE_BASE_URL=${node_agent_release_base_url}" \ + --build-arg "NODE_AGENT_COMMIT=${node_agent_commit}" \ + --build-arg "NODE_AGENT_PACKAGE_ID=${node_agent_package_id}" \ + --build-arg "NODE_AGENT_ARCHIVE_SHA256=${node_agent_archive_sha256}" \ + --build-arg "NODE_AGENT_BINARY_SHA256=${node_agent_binary_sha256}" \ + --build-arg "NODE_AGENT_APPLY_SHA256=${node_agent_apply_sha256}" \ --build-arg "ORCHESTRATION_ENGINE_RELEASE_BASE_URL=${orchestration_engine_release_base_url}" \ --build-arg "ORCHESTRATION_ENGINE_RELEASE_TAG=${orchestration_engine_release_tag}" \ --build-arg "ORCHESTRATION_ENGINE_ARTIFACT=${orchestration_engine_artifact}" \ @@ -168,14 +291,31 @@ docker buildx build \ --build-arg "WEBHOOK_AUTOMATION_SERVICE_COMMIT=${webhook_automation_service_commit}" \ --build-arg "WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256=${webhook_automation_service_archive_sha256}" \ --build-arg "WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256=${webhook_automation_service_binary_sha256}" \ + --build-arg "COMPOSE_EXECUTOR_RELEASE_BASE_URL=${compose_executor_release_base_url}" \ --build-arg "COMPOSE_EXECUTOR_VERSION=${compose_executor_version}" \ --build-arg "COMPOSE_EXECUTOR_COMMIT=${compose_executor_commit}" \ --build-arg "COMPOSE_EXECUTOR_ARCHIVE_SHA256=${compose_executor_archive_sha256}" \ --build-arg "COMPOSE_EXECUTOR_BINARY_SHA256=${compose_executor_binary_sha256}" \ + --build-arg "VSPHERE_CLI_BUNDLE_RELEASE_BASE_URL=${vsphere_cli_bundle_release_base_url}" \ --build-arg "VSPHERE_CLI_BUNDLE_VERSION=${vsphere_cli_bundle_version}" \ --build-arg "VSPHERE_CLI_BUNDLE_COMMIT=${vsphere_cli_bundle_commit}" \ --build-arg "VSPHERE_CLI_BUNDLE_ARCHIVE_SHA256=${vsphere_cli_bundle_archive_sha256}" \ --build-arg "GOVC_BINARY_SHA256=${govc_binary_sha256}" \ + --build-arg "HOST_PROVISIONER_RELEASE_BASE_URL=${host_provisioner_release_base_url}" \ + --build-arg "HOST_PROVISIONER_VERSION=${host_provisioner_version}" \ + --build-arg "HOST_PROVISIONER_COMMIT=${host_provisioner_commit}" \ + --build-arg "HOST_PROVISIONER_ARCHIVE_SHA256=${host_provisioner_archive_sha256}" \ + --build-arg "HOST_PROVISIONER_BINARY_SHA256=${host_provisioner_binary_sha256}" \ + --build-arg "SECRET_DELIVERY_API_RELEASE_BASE_URL=${secret_delivery_api_release_base_url}" \ + --build-arg "SECRET_DELIVERY_API_VERSION=${secret_delivery_api_version}" \ + --build-arg "SECRET_DELIVERY_API_COMMIT=${secret_delivery_api_commit}" \ + --build-arg "SECRET_DELIVERY_API_ARCHIVE_SHA256=${secret_delivery_api_archive_sha256}" \ + --build-arg "SECRET_DELIVERY_API_BINARY_SHA256=${secret_delivery_api_binary_sha256}" \ + --build-arg "USAGE_TELEMETRY_AGENT_RELEASE_BASE_URL=${usage_telemetry_agent_release_base_url}" \ + --build-arg "USAGE_TELEMETRY_AGENT_VERSION=${usage_telemetry_agent_version}" \ + --build-arg "USAGE_TELEMETRY_AGENT_COMMIT=${usage_telemetry_agent_commit}" \ + --build-arg "USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256=${usage_telemetry_agent_archive_sha256}" \ + --build-arg "USAGE_TELEMETRY_AGENT_BINARY_SHA256=${usage_telemetry_agent_binary_sha256}" \ --build-arg "SUPPORTED_DOCKER_RANGE=${supported_docker_range}" \ --build-arg "NEWEST_DOCKER_VERSION=${newest_docker_version}" \ --tag "$image" \ @@ -184,7 +324,7 @@ docker buildx build \ test "$(docker image inspect "$image" \ --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" = \ - v1.6.518 + "$server_release_tag" test "$(docker image inspect "$image" \ --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = \ "$revision" @@ -198,17 +338,22 @@ test "$(docker image inspect "$image" \ image_environment=$(docker image inspect "$image" \ --format '{{range .Config.Env}}{{println .}}{{end}}') for marker in \ - CATTLE_RANCHER_SERVER_VERSION=v1.6.518 \ + CATTLE_RANCHER_SERVER_VERSION="${server_release_tag}" \ CATTLE_API_UI_VERSION=1.1.18 \ - CATTLE_CATTLE_VERSION=v0.183.333 \ - RC16_GO_AGENT_VERSION=0.13.27 \ + CATTLE_CATTLE_VERSION="${orchestration_engine_release_tag}" \ + RC16_GO_AGENT_VERSION="${node_agent_version}" \ RC16_WINDOWS_AGENT_VERSION=0.13.27 \ - RC16_AGENT_PACKAGE_URL=/usr/share/cattle/artifacts/node-agent-0.13.27.tar.gz \ - PASTURESTACK_NODE_AGENT_VERSION=0.13.27 \ - PASTURESTACK_NODE_AGENT_COMMIT=d78a0817214bb21e3537730e68e12ebb74cbdaca \ + RC16_HOST_API_VERSION="${host_api_version}" \ + PASTURESTACK_HOST_API_PACKAGE_ID="${host_api_package_id}" \ + PASTURESTACK_HOST_API_COMMIT="${host_api_commit}" \ + PASTURESTACK_HOST_API_ARCHIVE_SHA256="${host_api_archive_sha256}" \ + RC16_AGENT_PACKAGE_URL="/usr/share/cattle/artifacts/node-agent-${node_agent_version}.tar.gz" \ + PASTURESTACK_NODE_AGENT_VERSION="${node_agent_version}" \ + PASTURESTACK_NODE_AGENT_COMMIT="${node_agent_commit}" \ + PASTURESTACK_NODE_AGENT_ARCHIVE_SHA256="${node_agent_archive_sha256}" \ PASTURESTACK_ORCHESTRATION_ENGINE_COMMIT="${orchestration_engine_commit}" \ PASTURESTACK_ORCHESTRATION_ENGINE_ARTIFACT_SHA256="${orchestration_engine_artifact_sha256}" \ - PASTURESTACK_RUNTIME_GO_VERSION=1.27.0 \ + PASTURESTACK_RUNTIME_GO_VERSION=1.27.2 \ PASTURESTACK_UBUNTU_SECURITY_REFRESH=2026-09-10 \ PASTURESTACK_CURL_SECURITY_SNAPSHOT=20261002T000000Z \ PASTURESTACK_CURL_PACKAGE_VERSION=8.18.0-1ubuntu2.7 \ @@ -217,6 +362,7 @@ for marker in \ PASTURESTACK_PERL_PACKAGE_VERSION=5.40.1-7ubuntu0.3 \ PASTURESTACK_LIBDBI_PERL_PACKAGE_VERSION=1.647-1ubuntu0.26.04.3 \ PASTURESTACK_FREETYPE_PACKAGE_VERSION=2.14.2+dfsg-1ubuntu0.2 \ + PASTURESTACK_LIBPNG_PACKAGE_VERSION=1.6.57-1ubuntu0.1 \ PASTURESTACK_COREUTILS_PROVIDER=gnu \ PASTURESTACK_COREUTILS_UNIQ_VERSION=9.11 \ PASTURESTACK_COREUTILS_UNIQ_FIX=d64e35a8a4c0e4608321433e0d84d917e4e36371 \ @@ -230,7 +376,7 @@ for marker in \ PASTURESTACK_RUNTIME_USER_MAPPING=removed \ PASTURESTACK_GPG_VERIFIER=removed \ PASTURESTACK_CONTAINER_SOURCE_BUILD_MODE=removed \ - PASTURESTACK_CONSOLE_BROKER_GO_VERSION=1.27.0 \ + PASTURESTACK_CONSOLE_BROKER_GO_VERSION=1.27.2 \ PASTURESTACK_API_EXPLORER_PACKAGE=1.1.18 \ PASTURESTACK_API_EXPLORER_COMMIT="${api_explorer_commit}" \ PASTURESTACK_API_EXPLORER_ARTIFACT_SHA256="${api_explorer_artifact_sha256}" \ @@ -250,9 +396,18 @@ for marker in \ PASTURESTACK_COMPOSE_EXECUTOR_COMMIT="${compose_executor_commit}" \ PASTURESTACK_COMPOSE_EXECUTOR_ARCHIVE_SHA256="${compose_executor_archive_sha256}" \ PASTURESTACK_COMPOSE_EXECUTOR_BINARY_SHA256="${compose_executor_binary_sha256}" \ - PASTURESTACK_HOST_PROVISIONER_VERSION=0.39.7 \ - PASTURESTACK_SECRET_DELIVERY_API_VERSION=0.3.1 \ - PASTURESTACK_USAGE_TELEMETRY_AGENT_VERSION=0.4.1 \ + PASTURESTACK_HOST_PROVISIONER_VERSION="${host_provisioner_version}" \ + PASTURESTACK_HOST_PROVISIONER_COMMIT="${host_provisioner_commit}" \ + PASTURESTACK_HOST_PROVISIONER_ARCHIVE_SHA256="${host_provisioner_archive_sha256}" \ + PASTURESTACK_HOST_PROVISIONER_BINARY_SHA256="${host_provisioner_binary_sha256}" \ + PASTURESTACK_SECRET_DELIVERY_API_VERSION="${secret_delivery_api_version}" \ + PASTURESTACK_SECRET_DELIVERY_API_COMMIT="${secret_delivery_api_commit}" \ + PASTURESTACK_SECRET_DELIVERY_API_ARCHIVE_SHA256="${secret_delivery_api_archive_sha256}" \ + PASTURESTACK_SECRET_DELIVERY_API_BINARY_SHA256="${secret_delivery_api_binary_sha256}" \ + PASTURESTACK_USAGE_TELEMETRY_AGENT_VERSION="${usage_telemetry_agent_version}" \ + PASTURESTACK_USAGE_TELEMETRY_AGENT_COMMIT="${usage_telemetry_agent_commit}" \ + PASTURESTACK_USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256="${usage_telemetry_agent_archive_sha256}" \ + PASTURESTACK_USAGE_TELEMETRY_AGENT_BINARY_SHA256="${usage_telemetry_agent_binary_sha256}" \ PASTURESTACK_WEBHOOK_AUTOMATION_SERVICE_VERSION="${webhook_automation_service_version}" \ PASTURESTACK_WEBHOOK_AUTOMATION_SERVICE_COMMIT="${webhook_automation_service_commit}" \ PASTURESTACK_WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256="${webhook_automation_service_archive_sha256}" \ @@ -304,14 +459,31 @@ docker run --rm --entrypoint bash "$image" -lc ' fi ' -docker run --rm --entrypoint sh "$image" -eu -c ' +docker run --rm \ + --env "HOST_API_VERSION=${host_api_version}" \ + --env "HOST_API_PACKAGE_MODE=${host_api_package_mode}" \ + --env "HOST_API_ARCHIVE_SHA256=${host_api_archive_sha256}" \ + --env "NODE_AGENT_VERSION=${node_agent_version}" \ + --env "NODE_AGENT_ARCHIVE_SHA256=${node_agent_archive_sha256}" \ + --entrypoint sh "$image" -eu -c ' printf "%s\n" \ - "0cbf93ef6f90db8c5f6b8cf7d63c99f452b8fc43e93097a21cdb5bcc3007d475 /usr/share/cattle/artifacts/node-agent-0.13.27.tar.gz" \ + "$NODE_AGENT_ARCHIVE_SHA256 /usr/share/cattle/artifacts/node-agent-${NODE_AGENT_VERSION}.tar.gz" \ "b6a56f8833c31bc224b7baf20ceb1a252c027021efbe6265c1906f8478d7c2fa /usr/share/cattle/artifacts/node-agent-0.13.27-windows-amd64.zip" | sha256sum -c - - test "$(readlink /usr/share/cattle/artifacts/go-agent.tar.gz)" = node-agent-0.13.27.tar.gz + test "$(readlink /usr/share/cattle/artifacts/go-agent.tar.gz)" = "node-agent-${NODE_AGENT_VERSION}.tar.gz" . /usr/share/cattle/env_vars - test "$CATTLE_AGENT_PACKAGE_PYTHON_AGENT_URL" = /usr/share/cattle/artifacts/node-agent-0.13.27.tar.gz + test "$DEFAULT_CATTLE_AGENT_PACKAGE_PYTHON_AGENT_URL" = "/usr/share/cattle/artifacts/node-agent-${NODE_AGENT_VERSION}.tar.gz" + test "$CATTLE_AGENT_PACKAGE_PYTHON_AGENT_URL" = "/usr/share/cattle/artifacts/node-agent-${NODE_AGENT_VERSION}.tar.gz" test "$CATTLE_AGENT_PACKAGE_WINDOWS_AGENT_URL" = /usr/share/cattle/artifacts/node-agent-0.13.27-windows-amd64.zip + host_archive="/usr/share/cattle/artifacts/host-api-${HOST_API_VERSION}.tar.gz" + test -s "$host_archive" + test "$(readlink /usr/share/cattle/artifacts/host-api.tar.gz)" = "host-api-${HOST_API_VERSION}.tar.gz" + test "$DEFAULT_CATTLE_AGENT_PACKAGE_HOST_API_URL" = "$host_archive" + test "$CATTLE_AGENT_PACKAGE_HOST_API_URL" = "$host_archive" + # Producer packages are installed byte-for-byte. The compatibility-only + # 518 repair intentionally changes its archive checksum, not its binaries. + if [ "$HOST_API_PACKAGE_MODE" = producer ]; then + printf "%s %s\n" "$HOST_API_ARCHIVE_SHA256" "$host_archive" | sha256sum -c - + fi ' image_catalog_license=$(docker run --rm --entrypoint sha256sum "$image" \ @@ -324,18 +496,22 @@ image_orchestration=$(docker run --rm --entrypoint sha256sum "$image" \ test "$image_orchestration" = \ "${orchestration_engine_artifact_sha256} /usr/share/cattle/cattle.jar" -docker run --rm --entrypoint bash "$image" -lc ' +docker run --rm \ + --env "ORCHESTRATION_ENGINE_VERSION=${orchestration_engine_version}" \ + --env "ENGINE_READONLY_SCHEMA_SHA256=${engine_readonly_schema_sha256}" \ + --env "ENGINE_RESTRICTED_SCHEMA_SHA256=${engine_restricted_schema_sha256}" \ + --entrypoint bash "$image" -lc ' set -euo pipefail engine_hash=$(sha256sum /usr/share/cattle/cattle.jar | awk "{print \$1}") web_root=$(readlink -f /usr/share/cattle/war) test "${web_root}" = "/usr/share/cattle/${engine_hash}" resources_jar=$(find "${web_root}/WEB-INF/lib" -maxdepth 1 -type f \ - -name "cattle-resources-0.183.333.jar" -print -quit) + -name "cattle-resources-${ORCHESTRATION_ENGINE_VERSION}.jar" -print -quit) test -n "${resources_jar}" test "$(unzip -p "${resources_jar}" schema/v1/readonly.ser | sha256sum | cut -d" " -f1)" = \ - "7f274219e8dd9c6d750a408a6edec1448b564f4b16ba2204c425c5b45cec2233" + "${ENGINE_READONLY_SCHEMA_SHA256}" test "$(unzip -p "${resources_jar}" schema/v1/restricted.ser | sha256sum | cut -d" " -f1)" = \ - "f854ba99260f29e324ab7446a8592996946038920ac9bcf17689d98d1ebc5e51" + "${ENGINE_RESTRICTED_SCHEMA_SHA256}" unzip -p "${resources_jar}" db/core-124.xml | grep -F "pasturestack-catalog-pinned-commit" >/dev/null unzip -p "${resources_jar}" schema/service/service-auth.json | @@ -378,10 +554,10 @@ test "$(docker run --rm --entrypoint sha256sum "$image" /usr/bin/websocket-proxy "${websocket_wrapper_sha256} /usr/bin/websocket-proxy" docker run --rm --entrypoint bash "$image" -lc 'test -x /usr/bin/websocket-proxy' -docker run --rm --entrypoint bash "$image" -lc ' +docker run --rm --env "WEB_CONSOLE_RELEASE_TAG=${web_console_release_tag}" --entrypoint bash "$image" -lc ' set -euo pipefail web_root=$(readlink -f /usr/share/cattle/war) - test "$(cat "${web_root}/VERSION.txt")" = "1.6.180" + test "$(cat "${web_root}/VERSION.txt")" = "${WEB_CONSOLE_RELEASE_TAG}" test "$(find "${web_root}/translations" -maxdepth 1 -type f -name "*.json" | wc -l)" -eq 13 test ! -e "${web_root}/translations/none.json" test -z "$(find "${web_root}" -type f -name "*.map" -print -quit)" @@ -527,7 +703,7 @@ docker run --rm --entrypoint bash "$image" -lc ' grep -F "\"auditLogsPage.filterBuilder.timeDialog.calendar.today\":\"Today\"" "${web_root}/translations/en-us.json" >/dev/null ' -docker run --rm --entrypoint bash "$image" -lc ' +docker run --rm --env "ORCHESTRATION_ENGINE_VERSION=${orchestration_engine_version}" --entrypoint bash "$image" -lc ' set -euo pipefail api_dir=/usr/share/cattle/war/api-ui test -d "${api_dir}" @@ -572,10 +748,10 @@ docker run --rm --entrypoint bash "$image" -lc ' /usr/share/cattle/war/translations/zh-tw.json >/dev/null unzip -p /usr/share/cattle/cattle.jar META-INF/MANIFEST.MF | tr -d "\r" | - grep -Fx "Implementation-Version: 0.183.333" >/dev/null + grep -Fx "Implementation-Version: ${ORCHESTRATION_ENGINE_VERSION}" >/dev/null test "$(find /usr/share/cattle/war/WEB-INF/lib -maxdepth 1 -type f -name "freemarker-2.3.35.jar" | wc -l)" -eq 1 resources_jar=$(find /usr/share/cattle/war/WEB-INF/lib -maxdepth 1 -type f \ - -name "cattle-resources-0.183.333.jar" -print -quit) + -name "cattle-resources-${ORCHESTRATION_ENGINE_VERSION}.jar" -print -quit) test -n "${resources_jar}" unzip -p "${resources_jar}" schema/user/user-auth.json | grep -F "\"volume.isNative\" : \"r\"" >/dev/null @@ -603,15 +779,15 @@ docker run --rm --entrypoint bash "$image" -lc ' sha256sum -c - rm -f /tmp/hazelcast.jar cat <<'"'"'EOF'"'"' | sha256sum -c - -feaabe4bba85cbe119c98a79a27abb4510401fc051f34d02aa7b48d69bdbe746 /usr/bin/authentication-service.real -1f542ee2dd76c7af06bc5f056c381d7e77aecaeac40f8d897df6df24a9902c0d /usr/bin/compose-executor.real -bce26b98133d3f5d4ecaddba26179ed8e14e5b260b38dee5f9e4383cbfbc855a /usr/bin/host-provisioner.real -fbdd12862e1cfe3c957f492ae81c4c1c5658357502bd322febbbe209496929be /usr/bin/secret-delivery-api -f18ed969b8b5959293fdbcd55d2e28846372ab87c9348fbb315a9a490bf85ad4 /usr/bin/usage-telemetry-agent -9094f3b2527762a3e683b02d93aa00e52618e902cd409e72e34553d98d98a609 /usr/bin/webhook-automation-service -efd0c78779a620b4b0f74a10eb3f3edd8886e8d23f22dc4624d8e9971085a26d /usr/bin/websocket-proxy.real -f8c7d82a614655c83ee119e3f170a302a9b35d9ca7efd13bbc226df2d68e5d31 /usr/bin/govc +fe11eec4b31b43863b49a582b1dbbe309eae08fb78adc150037981174f0622da /usr/bin/authentication-service.real +a9bf9f0f77e914fe557d3e178a73c31526b0ca0adc4afbf17bf68d8d75c7ee27 /usr/bin/compose-executor.real +1d37e20a7a1cf4f3e36036a15ff7699ef22cd8809fd14a20892034dd054fd1fc /usr/bin/host-provisioner.real +b5e01b7e65aeee3b456fe043142b5e70f4039ee89ffd437648368ff63e6042b6 /usr/bin/secret-delivery-api +e62a21270142181315293d7e11482288ffe8fc4d91cc8fe07dacc90b461f3709 /usr/bin/usage-telemetry-agent +98c7faea665b7eb95206b8c73a6f47d644c5d2d0eae53f274f6a15faf5205744 /usr/bin/webhook-automation-service EOF + echo "${PASTURESTACK_GOVC_BINARY_SHA256} /usr/bin/govc" | sha256sum -c - + echo "${PASTURESTACK_WEBSOCKET_PROXY_BINARY_SHA256} /usr/bin/websocket-proxy.real" | sha256sum -c - echo "${PASTURESTACK_CATALOG_SERVICE_BINARY_SHA256} /usr/bin/catalog-service.real" | sha256sum -c - echo "${PASTURESTACK_CATALOG_SERVICE_SQLITE_BINARY_SHA256} /usr/bin/catalog-service-sqlite" | sha256sum -c - for binary in \ @@ -627,9 +803,9 @@ EOF /usr/bin/govc \ /usr/bin/pasturestack-console-broker; do test -x "${binary}" - grep -aF "go1.27.0" "${binary}" >/dev/null + grep -aF "go1.27.2" "${binary}" >/dev/null done - /usr/bin/authentication-service.real --version | grep -F "0.4.42" >/dev/null + /usr/bin/authentication-service.real --version | grep -F "0.4.43" >/dev/null for marker in \ oidcAccessPolicyUpdate \ LocalRecoveryRequired \ @@ -647,13 +823,16 @@ EOF test -s /usr/share/licenses/pasturestack/catalog-service/LICENSE.txt grep -Fx "Release source commit: ${PASTURESTACK_CATALOG_SERVICE_COMMIT}" \ /usr/share/licenses/pasturestack/catalog-service/SOURCES.txt >/dev/null - /usr/bin/secret-delivery-api --version | grep -F "v0.3.1" >/dev/null - /usr/bin/usage-telemetry-agent --version | grep -F "0.4.1" >/dev/null - /usr/bin/webhook-automation-service --version | grep -F "0.10.3" >/dev/null + /usr/bin/secret-delivery-api --version | grep -F "v0.3.2" >/dev/null + /usr/bin/usage-telemetry-agent --version | grep -F "0.4.2" >/dev/null + /usr/bin/webhook-automation-service --version | grep -F "0.10.4" >/dev/null test "$(readlink -f /usr/bin/webhook-service)" = /usr/bin/webhook-automation-service - grep -Fx "Release source commit: fbcc0ca07e42e9b21bda18031d0848192ec2f9a1" \ + grep -Fx "Release source commit: 400118b893843d2a7d7c65cc70c3449d76c4a8d8" \ /usr/share/licenses/pasturestack/webhook-automation-service/webhook-automation-service-SOURCES.txt >/dev/null - test "$(/usr/bin/govc version)" = "govc 0.55.2" + test "$(/usr/bin/govc version)" = "govc ${PASTURESTACK_VSPHERE_CLI_BUNDLE_VERSION}" + grep -aF "$(printf "dep\tgolang.org/x/text\tv0.41.0\t")" /usr/bin/govc >/dev/null + grep -Fx "Security dependency: golang.org/x/text v0.41.0" \ + /usr/share/licenses/pasturestack/vsphere-cli-bundle/vsphere-cli-bundle-SOURCES.txt >/dev/null version_at_least() { local package=$1 minimum=$2 installed @@ -719,6 +898,11 @@ EOF test "$(dpkg-query -W -f='"'"'${Version}'"'"' libfreetype6)" = 2.14.2+dfsg-1ubuntu0.2 test "$(wc -l < /usr/share/pasturestack/security/freetype-runtime.sha256)" -eq 1 sha256sum -c /usr/share/pasturestack/security/freetype-runtime.sha256 + test "$(dpkg-query -W -f='"'"'${Version}'"'"' libpng16-16t64)" = 1.6.57-1ubuntu0.1 + test "$(wc -l < /usr/share/pasturestack/security/libpng-runtime.sha256)" -eq 1 + sha256sum -c /usr/share/pasturestack/security/libpng-runtime.sha256 + libpng_linkage=$(ldd -r /usr/lib/x86_64-linux-gnu/libpng16.so.16 2>&1) + ! printf "%s\n" "$libpng_linkage" | grep -E "not found|undefined symbol" test "$(readlink -f /usr/lib/x86_64-linux-gnu/libfreetype.so.6)" = /usr/lib/x86_64-linux-gnu/libfreetype.so.6.20.5 perl -MDBI -e '"'"'die "Unexpected DBI runtime version\n" unless $DBI::VERSION eq "1.647"'"'"' openssl version | grep -E "^OpenSSL 3\\.5\\.5 .*\\(Library: OpenSSL 3\\.5\\.5 " >/dev/null @@ -782,7 +966,7 @@ EOF docker run --rm -i --network none --entrypoint bash "$image" -s \ < scripts/test-server-openssl-tls.sh -printf 'SERVER_API_EXPLORER_PATCH_IMAGE_OK image=%s revision=%s base=%s orchestration=%s orchestration_commit=%s orchestration_sha256=%s api_explorer=%s api_explorer_commit=%s artifact_sha256=%s web_console=%s web_console_commit=%s web_console_sha256=%s catalog_service=%s catalog_service_commit=%s catalog_service_archive_sha256=%s catalog_service_binary_sha256=%s catalog_service_sqlite_binary_sha256=%s authentication_service=%s authentication_service_commit=%s authentication_service_archive_sha256=%s authentication_service_binary_sha256=%s websocket_proxy=%s websocket_proxy_commit=%s websocket_proxy_archive_sha256=%s websocket_proxy_binary_sha256=%s compose_executor=%s compose_executor_commit=%s compose_executor_archive_sha256=%s compose_executor_binary_sha256=%s vsphere_cli=%s vsphere_cli_commit=%s vsphere_cli_archive_sha256=%s govc_binary_sha256=%s runtime_go=1.27.0 orchestration_updated=1 wrappers_pinned=1 audit_log_filters=1 curl=8.18.0-1ubuntu2.7 freemarker=2.3.35 vendor_pending=exact-set artifact_scan=required\n' \ +printf 'SERVER_API_EXPLORER_PATCH_IMAGE_OK image=%s revision=%s base=%s orchestration=%s orchestration_commit=%s orchestration_sha256=%s api_explorer=%s api_explorer_commit=%s artifact_sha256=%s web_console=%s web_console_commit=%s web_console_sha256=%s catalog_service=%s catalog_service_commit=%s catalog_service_archive_sha256=%s catalog_service_binary_sha256=%s catalog_service_sqlite_binary_sha256=%s authentication_service=%s authentication_service_commit=%s authentication_service_archive_sha256=%s authentication_service_binary_sha256=%s websocket_proxy=%s websocket_proxy_commit=%s websocket_proxy_archive_sha256=%s websocket_proxy_binary_sha256=%s compose_executor=%s compose_executor_commit=%s compose_executor_archive_sha256=%s compose_executor_binary_sha256=%s vsphere_cli=%s vsphere_cli_commit=%s vsphere_cli_archive_sha256=%s govc_binary_sha256=%s runtime_go=1.27.2 orchestration_updated=1 wrappers_pinned=1 audit_log_filters=1 curl=8.18.0-1ubuntu2.7 freemarker=2.3.35 vendor_pending=exact-set artifact_scan=required\n' \ "$image" "$revision" "$base_image" "${orchestration_engine_release_tag#v}" \ "$orchestration_engine_commit" "$orchestration_engine_artifact_sha256" \ "${api_explorer_release_tag#v}" "$api_explorer_commit" "$api_explorer_artifact_sha256" \ diff --git a/server/build-component-candidate.sh b/server/build-component-candidate.sh new file mode 100755 index 00000000000..55cfd1da5b5 --- /dev/null +++ b/server/build-component-candidate.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# The same formal recipe and verification gates consume local and published +# assets. New identities are mandatory: never inherit the 518 artifact hashes. +required=(ORCHESTRATION_ENGINE_COMMIT ORCHESTRATION_ENGINE_ARTIFACT_SHA256 + ENGINE_READONLY_SCHEMA_SHA256 ENGINE_RESTRICTED_SCHEMA_SHA256 + WEB_CONSOLE_COMMIT WEB_CONSOLE_ARTIFACT_SHA256 WEBSOCKET_PROXY_COMMIT + WEBSOCKET_PROXY_ARCHIVE_SHA256 WEBSOCKET_PROXY_BINARY_SHA256 HOST_API_COMMIT + HOST_API_ARCHIVE_SHA256 HOST_API_BINARY_SHA256 HOST_API_APPLY_SHA256 + NODE_AGENT_COMMIT NODE_AGENT_ARCHIVE_SHA256 NODE_AGENT_BINARY_SHA256 NODE_AGENT_APPLY_SHA256) +for name in "${required[@]}"; do + [[ -n ${!name:-} ]] || { printf 'Missing required component identity: %s\n' "$name" >&2; exit 2; } + case "$name" in *_COMMIT) [[ ${!name} =~ ^[0-9a-f]{40}$ ]] ;; *) [[ ${!name} =~ ^[0-9a-f]{64}$ ]] ;; esac + export "$name" +done +export SERVER_RELEASE_TAG=${SERVER_RELEASE_TAG:-v1.6.519} +export ORCHESTRATION_ENGINE_RELEASE_TAG=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.335} +export ORCHESTRATION_ENGINE_ARTIFACT=cattle.jar +export WEB_CONSOLE_RELEASE_TAG=${WEB_CONSOLE_RELEASE_TAG:-1.6.181} +export WEB_CONSOLE_ARTIFACT="web-console-${WEB_CONSOLE_RELEASE_TAG}.tar.gz" +export WEBSOCKET_PROXY_VERSION=${WEBSOCKET_PROXY_VERSION:-0.23.15} +export HOST_API_VERSION=${HOST_API_VERSION:-0.38.5} +export NODE_AGENT_VERSION=${NODE_AGENT_VERSION:-0.13.28} +export HOST_API_PACKAGE_MODE=producer +export HOST_API_PACKAGE_ID=${HOST_API_PACKAGE_ID:-${HOST_API_COMMIT:0:32}} +[[ "$HOST_API_PACKAGE_ID" == "${HOST_API_COMMIT:0:32}" ]] +export NODE_AGENT_PACKAGE_ID=${NODE_AGENT_PACKAGE_ID:-${NODE_AGENT_COMMIT:0:32}} +[[ "$NODE_AGENT_PACKAGE_ID" == "${NODE_AGENT_COMMIT:0:32}" ]] +export HOST_API_RELEASE_BASE_URL=https://github.com/PastureStack/host-api/releases/download +export HOST_API_RELEASE_TAG="v${HOST_API_VERSION}" +export IMAGE=${IMAGE:-pasturestack-validation/server:${SERVER_RELEASE_TAG}} +[[ "$SERVER_RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] +[[ "$ORCHESTRATION_ENGINE_RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] +[[ "$WEB_CONSOLE_RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +[[ "$WEBSOCKET_PROXY_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +[[ "$HOST_API_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +[[ "$NODE_AGENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +if [[ ${1:-} == --check-inputs ]]; then + [[ $# == 1 ]] + printf 'SERVER_COMPONENT_INPUTS_OK server=%s engine=%s web=%s proxy=%s host=%s node=%s package_id=%s\n' \ + "$SERVER_RELEASE_TAG" "$ORCHESTRATION_ENGINE_RELEASE_TAG" "$WEB_CONSOLE_RELEASE_TAG" \ + "$WEBSOCKET_PROXY_VERSION" "$HOST_API_VERSION" "$NODE_AGENT_VERSION" "$HOST_API_PACKAGE_ID" + exit 0 +fi +[[ $# == 0 ]] || { echo 'usage: build-component-candidate.sh [--check-inputs]' >&2; exit 2; } +exec bash "$(dirname "${BASH_SOURCE[0]}")/build-api-explorer-patch-image.sh" diff --git a/server/console-broker/audit.go b/server/console-broker/audit.go index 4a0891fbee9..cb57d73eb11 100644 --- a/server/console-broker/audit.go +++ b/server/console-broker/audit.go @@ -29,6 +29,15 @@ type auditQuery struct { From time.Time To time.Time AllTime bool + KeyID string + KeyOwnerAccountID string + ViewerAccountID string + Decision string + Outcome string + HTTPStatus string + Operation string + TargetType string + RequestID string EnvironmentID string UserID string EventType string @@ -76,6 +85,8 @@ type auditCollection struct { type auditUpstreamCollection struct { Data []map[string]any `json:"data"` Pagination map[string]any `json:"pagination"` + // Principal identity comes only from the Engine response header, never JSON. + viewerAccountID string } type auditHTTPError struct { @@ -158,9 +169,12 @@ func parseAuditQuery(values url.Values, now time.Time) (auditQuery, error) { fromValue := firstNonEmpty(values.Get("created_gte"), values.Get("createdFrom")) toValue := firstNonEmpty(values.Get("created_lte"), values.Get("createdTo")) timeScope := values.Get("timeScope") - if timeScope != "" && timeScope != "all" { + if timeScope != "" && timeScope != "all" && timeScope != "range" { return auditQuery{}, &auditHTTPError{Status: http.StatusBadRequest, Code: "invalid_time_scope", Message: "Unsupported audit log time scope"} } + if timeScope == "range" && (fromValue == "" || toValue == "") { + return auditQuery{}, &auditHTTPError{Status: http.StatusBadRequest, Code: "incomplete_time_range", Message: "Both audit log time boundaries are required"} + } if fromValue == "" && toValue == "" { query.To = now query.AllTime = timeScope == "all" @@ -199,6 +213,12 @@ func parseAuditQuery(values url.Values, now time.Time) (auditQuery, error) { query.Channel = strings.ToLower(cleanAuditValue(values.Get("interactionChannel"), 64)) query.EventType, query.EventTypeOperator = parseTextAuditFilter(values, "eventType") query.Description, query.DescriptionOperator = parseTextAuditFilter(values, "description") + query.Decision = cleanAuditValue(values.Get("decision"), 32) + query.Outcome = cleanAuditValue(values.Get("outcome"), 32) + query.HTTPStatus = cleanAuditValue(values.Get("httpStatus"), 8) + query.Operation = cleanAuditValue(values.Get("operation"), 64) + query.TargetType = cleanAuditValue(values.Get("targetType"), 160) + query.RequestID = cleanAuditValue(values.Get("requestId"), 512) if query.Channel != "" { if !validAuditChannel(query.Channel) { @@ -238,34 +258,75 @@ func cleanAuditValue(value string, maximum int) string { return value } -func (b *broker) runAuditQuery(ctx context.Context, incoming *http.Request, query auditQuery) (auditResult, error) { - projects, err := b.fetchAuditCollection(ctx, incoming, "/v2-beta/projects", url.Values{ +func (b *broker) fetchAuditProjects(ctx context.Context, incoming *http.Request, keyQuery bool) (map[string]string, string, error) { + projectPage, err := b.fetchAuditPagePolicy(ctx, incoming, "/v2-beta/projects", url.Values{ "all": {"true"}, "limit": {"1000"}, - }) + }, keyQuery) if err != nil { - return auditResult{}, err + return nil, "", err + } + if keyQuery { + if len(projectPage.Data) > auditUpstreamPageSize { + return nil, "", &auditHTTPError{Status: http.StatusUnprocessableEntity, Code: "result_set_too_large", Message: "Current environment permissions exceeded the bounded authority query"} + } + if next, present := projectPage.Pagination["next"]; present && next != nil { + value, valid := next.(string) + if !valid { + return nil, "", &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_key_audit_scope", Message: "Current environment permissions could not be verified"} + } + if value != "" { + return nil, "", &auditHTTPError{Status: http.StatusUnprocessableEntity, Code: "result_set_too_large", Message: "Narrow the visible environment scope before continuing; current permissions cannot be completely verified within this bounded query"} + } + } + if !keyAuditIDPattern.MatchString(projectPage.viewerAccountID) { + return nil, "", &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_key_authority", Message: "The current user could not be verified"} + } } - allowedProjects := make(map[string]string) - for _, project := range projects { + for _, project := range projectPage.Data { if !strings.EqualFold(auditString(project, "type"), "project") { continue } id := auditString(project, "id") + if keyQuery { + _, duplicate := allowedProjects[id] + _, stringID := project["id"].(string) + if !stringID || !keyAuditIDPattern.MatchString(id) || duplicate { + return nil, "", &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_key_audit_scope", Message: "Current environment permissions could not be verified"} + } + } if id == "" { continue } allowedProjects[id] = firstNonEmpty(auditString(project, "displayName"), auditString(project, "name")) } + return allowedProjects, projectPage.viewerAccountID, nil +} + +func (b *broker) runAuditQuery(ctx context.Context, incoming *http.Request, query auditQuery) (auditResult, error) { + keyQuery := query.KeyID != "" + allowedProjects, viewer, err := b.fetchAuditProjects(ctx, incoming, keyQuery) + if err != nil { + return auditResult{}, err + } + if keyQuery && viewer != query.ViewerAccountID { + return auditResult{}, &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_key_authority", Message: "The current user changed while verifying API key permissions"} + } + if query.KeyID != "" && query.KeyOwnerAccountID != query.ViewerAccountID { + if _, allowed := allowedProjects[query.KeyOwnerAccountID]; !allowed { + return auditResult{}, &auditHTTPError{Status: http.StatusForbidden, Code: "key_audit_access_lost", Message: "You no longer have access to this API key or its environment"} + } + } if query.EnvironmentID != "" { if _, allowed := allowedProjects[query.EnvironmentID]; !allowed { return auditResult{}, &auditHTTPError{Status: http.StatusForbidden, Code: "environment_denied", Message: "The requested environment is not available to this user"} } } - accounts, accountErr := b.fetchAuditCollection(ctx, incoming, "/v2-beta/accounts", url.Values{ + accountPage, accountErr := b.fetchAuditPagePolicy(ctx, incoming, "/v2-beta/accounts", url.Values{ "limit": {"1000"}, "kind_ne": {"service", "agent"}, - }) + }, keyQuery) + accounts := accountPage.Data if accountErr != nil { var upstreamErr *auditHTTPError if !errors.As(accountErr, &upstreamErr) || (upstreamErr.Status != http.StatusForbidden && upstreamErr.Status != http.StatusNotFound) { @@ -273,6 +334,9 @@ func (b *broker) runAuditQuery(ctx context.Context, incoming *http.Request, quer } accounts = nil } + if keyQuery && accountErr == nil && accountPage.viewerAccountID != query.ViewerAccountID { + return auditResult{}, &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_key_authority", Message: "The current user changed while verifying API key permissions"} + } accountNames := make(map[string]string) for _, account := range accounts { id := auditString(account, "id") @@ -289,20 +353,37 @@ func (b *broker) runAuditQuery(ctx context.Context, incoming *http.Request, quer } if !query.AllTime { upstreamValues.Set("created_gte", query.From.Format(time.RFC3339Nano)) + // Bound the upstream candidate set too; the local [from,to) check + // below still excludes records exactly at the inclusive Engine bound. + upstreamValues.Set("created_lte", query.To.Format(time.RFC3339Nano)) } if query.EnvironmentID != "" { upstreamValues.Set("accountId", query.EnvironmentID) } - records, err := b.fetchAllAuditLogs(ctx, incoming, upstreamValues) + var records []map[string]any + if query.KeyID != "" { + // AccountPolicy applies to the requested Engine environment, not all + // memberships at once. The Key view must query each live authorized + // environment explicitly rather than silently showing personal rows only. + records, err = b.fetchKeyAuditLogs(ctx, incoming, query, allowedProjects, upstreamValues) + } else { + records, err = b.fetchAllAuditLogs(ctx, incoming, upstreamValues) + } if err != nil { return auditResult{}, err } scoped := make([]map[string]any, 0, len(records)) for _, record := range records { + if query.KeyID != "" && keyAuditMetadataString(record, "keyId") != query.KeyID { + continue + } projectID := auditString(record, "accountId") if _, allowed := allowedProjects[projectID]; !allowed { - continue + // Personal-account events are visible only to that key's owner. + if query.KeyID == "" || query.KeyOwnerAccountID != query.ViewerAccountID || projectID != query.ViewerAccountID { + continue + } } created, ok := auditTimestamp(record) if !ok || (!query.AllTime && (created.Before(query.From) || !created.Before(query.To))) { @@ -313,6 +394,9 @@ func (b *broker) runAuditQuery(ctx context.Context, incoming *http.Request, quer } copyRecord := cloneAuditRecord(record) + if query.KeyID != "" { + copyRecord = safeKeyAuditRecord(record) + } copyRecord["environmentDisplayName"] = allowedProjects[projectID] actorID := auditString(record, "authenticatedAsAccountId") copyRecord["actorDisplayName"] = firstNonEmpty( @@ -348,13 +432,31 @@ func (b *broker) runAuditQuery(ctx context.Context, incoming *http.Request, quer } func (b *broker) fetchAllAuditLogs(ctx context.Context, incoming *http.Request, values url.Values) ([]map[string]any, error) { + return b.fetchAllAuditLogsForViewer(ctx, incoming, values, "") +} + +func (b *broker) fetchAllAuditLogsForViewer(ctx context.Context, incoming *http.Request, values url.Values, viewer string) ([]map[string]any, error) { requestPath := "/v2-beta/auditlogs" + // Next supplies pagination, not a new query. Keep server-selected + // bounds, scope and ordering fixed even when next omits or changes them. + fixedValues := make(url.Values, len(values)+4) + for name, items := range values { + fixedValues[name] = append([]string(nil), items...) + } + for _, name := range []string{"created_gte", "created_lte", "accountId", "projectId"} { + if _, present := fixedValues[name]; !present { + fixedValues[name] = nil // An unbounded/personal query stays that way. + } + } records := make([]map[string]any, 0, auditUpstreamPageSize) for page := 0; page < 100; page++ { - collection, err := b.fetchAuditPage(ctx, incoming, requestPath, values) + collection, err := b.fetchAuditPagePolicy(ctx, incoming, requestPath, values, viewer != "") if err != nil { return nil, err } + if viewer != "" && collection.viewerAccountID != viewer { + return nil, &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_key_authority", Message: "The current user changed while reading API key audit events"} + } if len(records)+len(collection.Data) > auditMaximumScanRows { return nil, &auditHTTPError{Status: http.StatusUnprocessableEntity, Code: "result_set_too_large", Message: "Narrow the audit log time range or environment before continuing"} } @@ -369,6 +471,12 @@ func (b *broker) fetchAllAuditLogs(ctx context.Context, incoming *http.Request, } requestPath = parsed.Path values = parsed.Query() + for name, items := range fixedValues { + values.Del(name) + if len(items) > 0 { + values[name] = append([]string(nil), items...) + } + } } return nil, &auditHTTPError{Status: http.StatusUnprocessableEntity, Code: "result_set_too_large", Message: "Narrow the audit log time range or environment before continuing"} } @@ -382,6 +490,10 @@ func (b *broker) fetchAuditCollection(ctx context.Context, incoming *http.Reques } func (b *broker) fetchAuditPage(ctx context.Context, incoming *http.Request, path string, values url.Values) (auditUpstreamCollection, error) { + return b.fetchAuditPagePolicy(ctx, incoming, path, values, false) +} + +func (b *broker) fetchAuditPagePolicy(ctx context.Context, incoming *http.Request, path string, values url.Values, keyQuery bool) (auditUpstreamCollection, error) { target := *b.upstreamURL target.Path = path target.RawPath = "" @@ -396,13 +508,18 @@ func (b *broker) fetchAuditPage(ctx context.Context, incoming *http.Request, pat request.Header.Set("Accept", "application/json") client := &http.Client{Timeout: 20 * time.Second} + if keyQuery { + client.CheckRedirect = func(*http.Request, []*http.Request) error { + return errors.New("API key audit redirects are not allowed") + } + } response, err := client.Do(request) if err != nil { return auditUpstreamCollection{}, &auditHTTPError{Status: http.StatusBadGateway, Code: "audit_upstream_unavailable", Message: "Audit log service is not available"} } defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { + if response.StatusCode < 200 || response.StatusCode >= 300 || (keyQuery && response.StatusCode != http.StatusOK) { _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) status := response.StatusCode if status != http.StatusUnauthorized && status != http.StatusForbidden && status != http.StatusNotFound { @@ -417,6 +534,7 @@ func (b *broker) fetchAuditPage(ctx context.Context, incoming *http.Request, pat if err := decoder.Decode(&collection); err != nil { return auditUpstreamCollection{}, &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_audit_response", Message: "Audit log service returned an invalid response"} } + collection.viewerAccountID = response.Header.Get("X-API-USER-ID") return collection, nil } @@ -429,6 +547,14 @@ func copyAuditAuthHeaders(target, source http.Header) { } func auditRecordMatches(record map[string]any, query auditQuery) bool { + for field, expected := range map[string]string{ + "decision": query.Decision, "outcome": query.Outcome, "httpStatus": query.HTTPStatus, + "operation": query.Operation, "targetType": query.TargetType, "requestId": query.RequestID, + } { + if expected != "" && auditString(record, field) != expected { + return false + } + } if query.UserID != "" && auditString(record, "authenticatedAsAccountId") != query.UserID { return false } diff --git a/server/console-broker/audit_range_test.go b/server/console-broker/audit_range_test.go new file mode 100644 index 00000000000..6430b4fd395 --- /dev/null +++ b/server/console-broker/audit_range_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "testing" + "time" +) + +func TestParseAuditQueryExplicitRangeUsesExistingBoundedWindow(t *testing.T) { + now := time.Date(2026, 10, 9, 10, 20, 0, 0, time.UTC) + cases := []struct { + name, from, to, code string + status int + }{ + {name: "native UI range", from: "2026-10-08T10:20:00Z", to: "2026-10-09T10:20:00Z"}, + {name: "timezone and subsecond", from: "2026-10-08T18:20:00.123+08:00", to: "2026-10-09T18:20:00.123+08:00"}, + {name: "maximum window", from: "2025-10-08T10:20:00Z", to: "2026-10-09T10:20:00Z"}, + {name: "both missing", code: "incomplete_time_range", status: http.StatusBadRequest}, + {name: "start missing", to: "2026-10-09T10:20:00Z", code: "incomplete_time_range", status: http.StatusBadRequest}, + {name: "end missing", from: "2026-10-08T10:20:00Z", code: "incomplete_time_range", status: http.StatusBadRequest}, + {name: "timezone missing", from: "2026-10-08T10:20:00", to: "2026-10-09T10:20:00Z", code: "invalid_time_range", status: http.StatusBadRequest}, + {name: "zero width", from: "2026-10-09T10:20:00Z", to: "2026-10-09T10:20:00Z", code: "invalid_time_range", status: http.StatusBadRequest}, + {name: "reversed", from: "2026-10-09T10:20:00Z", to: "2026-10-08T10:20:00Z", code: "invalid_time_range", status: http.StatusBadRequest}, + {name: "maximum exceeded", from: "2025-10-08T10:19:59Z", to: "2026-10-09T10:20:00Z", code: "time_range_too_large", status: http.StatusUnprocessableEntity}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + query, err := parseAuditQuery(url.Values{"timeScope": {"range"}, "created_gte": {tc.from}, "created_lte": {tc.to}}, now) + if tc.code != "" { + var failure *auditHTTPError + if !errors.As(err, &failure) || failure.Status != tc.status || failure.Code != tc.code { + t.Fatalf("expected %d %s, got %v", tc.status, tc.code, err) + } + return + } + from, _ := time.Parse(time.RFC3339Nano, tc.from) + to, _ := time.Parse(time.RFC3339Nano, tc.to) + if err != nil || query.AllTime || !query.From.Equal(from) || !query.To.Equal(to) || query.From.Location() != time.UTC || query.To.Location() != time.UTC { + t.Fatalf("explicit range changed the bounded UTC window: %#v %v", query, err) + } + }) + } +} + +func TestKeyAuditNativeRangeFiltersBeforeCountAndKeepsOwnerIsolation(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.accountScoped = true + fixture.records = []map[string]any{ + keyAuditRecord("before", "1a99", "1p1", "2026-10-08T10:19:59Z"), + keyAuditRecord("start", "1a99", "1a1", "2026-10-08T10:20:00Z"), + keyAuditRecord("middle", "1a99", "1p1", "2026-10-08T12:00:00Z"), + keyAuditRecord("end", "1a99", "1p1", "2026-10-09T10:20:00Z"), + keyAuditRecord("foreign", "1a98", "1p1", "2026-10-08T12:00:00Z"), + keyAuditRecord("hidden", "1a99", "1p9", "2026-10-08T12:00:00Z"), + } + query := "?keyId=1a99&timeScope=range&created_gte=2026-10-08T10%3A20%3A00Z&created_lte=2026-10-09T10%3A20%3A00Z" + response := performAuditRequest(t, server, keyAuditQueryPath+query+"&limit=1&offset=1&order=asc") + var payload auditCollection + err := json.NewDecoder(response.Body).Decode(&payload) + response.Body.Close() + if response.StatusCode != http.StatusOK || err != nil || payload.Pagination["total"] != float64(2) || len(payload.Data) != 1 || auditString(payload.Data[0], "id") != "middle" { + t.Fatalf("native range did not apply [from,to), Key, and live owner scope before pagination: %d %#v %v", response.StatusCode, payload, err) + } + for _, values := range fixture.auditQueries { + if values.Get("created_gte") != "2026-10-08T10:20:00Z" { + t.Fatalf("range lower boundary missing from live account-scoped scan: %#v", values) + } + } + // Detail remains independent of list time boundaries, but not of Key or RBAC. + for _, tc := range []struct { + id string + status int + }{{"before", http.StatusOK}, {"foreign", http.StatusNotFound}, {"hidden", http.StatusNotFound}} { + detail := performAuditRequest(t, server, keyAuditQueryPath+"/"+tc.id+query) + detail.Body.Close() + if detail.StatusCode != tc.status { + t.Fatalf("range detail %s escaped its independent live scope: %d", tc.id, detail.StatusCode) + } + } + fixture.mu.Lock() + fixture.owner = "1a2" + scans := len(fixture.auditQueries) + fixture.mu.Unlock() + foreign := performAuditRequest(t, server, keyAuditQueryPath+query) + foreign.Body.Close() + if foreign.StatusCode != http.StatusForbidden || len(fixture.auditQueries) != scans { + t.Fatalf("range bypassed foreign personal-Key ownership: %d", foreign.StatusCode) + } +} diff --git a/server/console-broker/audit_test.go b/server/console-broker/audit_test.go index 818668cff88..ad74d5bd1cf 100644 --- a/server/console-broker/audit_test.go +++ b/server/console-broker/audit_test.go @@ -3,6 +3,7 @@ package main import ( "archive/zip" "bytes" + "context" "encoding/json" "errors" "io" @@ -10,6 +11,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "sync" "testing" @@ -17,13 +19,16 @@ import ( ) type auditUpstreamFixture struct { - server *httptest.Server - mu sync.Mutex - auditQueries []url.Values - auditRecords []map[string]any - secondPage []map[string]any - allowedCookie string - allowedAuth string + server *httptest.Server + mu sync.Mutex + auditQueries []url.Values + auditRecords []map[string]any + secondPage []map[string]any + allowedCookie string + allowedAuth string + applyTimeBounds bool + nextAuditQuery string + viewerAccountID string } func newAuditUpstreamFixture(t *testing.T) *auditUpstreamFixture { @@ -36,6 +41,9 @@ func newAuditUpstreamFixture(t *testing.T) *auditUpstreamFixture { return } writer.Header().Set("Content-Type", "application/json") + if fixture.viewerAccountID != "" { + writer.Header().Set("X-API-USER-ID", fixture.viewerAccountID) + } switch request.URL.Path { case "/v2-beta/projects": writeFixtureCollection(writer, []map[string]any{ @@ -52,6 +60,42 @@ func newAuditUpstreamFixture(t *testing.T) *auditUpstreamFixture { fixture.mu.Lock() fixture.auditQueries = append(fixture.auditQueries, request.URL.Query()) fixture.mu.Unlock() + if fixture.applyTimeBounds { + values := request.URL.Query() + from, _ := time.Parse(time.RFC3339Nano, values.Get("created_gte")) + to, _ := time.Parse(time.RFC3339Nano, values.Get("created_lte")) + records := make([]map[string]any, 0, len(fixture.auditRecords)) + for _, record := range fixture.auditRecords { + created, valid := auditTimestamp(record) + if !valid || (!from.IsZero() && created.Before(from)) || (!to.IsZero() && created.After(to)) { + continue + } + records = append(records, record) + } + page, _ := strconv.Atoi(values.Get("page")) + if page < 1 { + page = 1 + } + limit, _ := strconv.Atoi(values.Get("limit")) + if limit < 1 || limit > auditUpstreamPageSize { + limit = auditUpstreamPageSize + } + start := (page - 1) * limit + if start > len(records) { + start = len(records) + } + end := start + limit + if end > len(records) { + end = len(records) + } + next := "" + if end < len(records) { + values.Set("page", strconv.Itoa(page+1)) + next = fixture.server.URL + "/v2-beta/auditlogs?" + values.Encode() + } + writeFixtureCollection(writer, records[start:end], next) + return + } if request.URL.Query().Get("page") == "2" { writeFixtureCollection(writer, fixture.secondPage, "") return @@ -59,6 +103,9 @@ func newAuditUpstreamFixture(t *testing.T) *auditUpstreamFixture { next := "" if fixture.secondPage != nil { next = fixture.server.URL + "/v2-beta/auditlogs?page=2" + if fixture.nextAuditQuery != "" { + next = fixture.server.URL + "/v2-beta/auditlogs?" + fixture.nextAuditQuery + } } writeFixtureCollection(writer, fixture.auditRecords, next) default: @@ -172,8 +219,174 @@ func TestAuditQueryEnforcesBothTimeBoundariesAndEnvironmentAuthorization(t *test if len(fixture.auditQueries) != 2 { t.Fatalf("expected two upstream pages, got %d", len(fixture.auditQueries)) } - if fixture.auditQueries[0].Get("created_gte") == "" || fixture.auditQueries[0].Get("created_lte") != "" { - t.Fatalf("upstream query must use the supported lower boundary only: %#v", fixture.auditQueries[0]) + if fixture.auditQueries[0].Get("created_gte") != "2026-08-29T02:00:00Z" || fixture.auditQueries[0].Get("created_lte") != "2026-08-29T03:00:00Z" { + t.Fatalf("upstream query must bound both ends of the candidate range: %#v", fixture.auditQueries[0]) + } +} + +func TestAuditHistoricalRangeExcludesNewerRowsBeforeScanGuard(t *testing.T) { + from, to := "2026-08-29T02:00:00Z", "2026-08-29T03:00:00Z" + rangeValues := url.Values{"timeScope": {"range"}, "created_gte": {from}, "created_lte": {to}} + cases := []struct { + name string + path string + values url.Values + status int + }{ + {name: "historical range page", path: auditQueryPath, values: rangeValues, status: http.StatusOK}, + {name: "all with explicit dates remains a range", path: auditQueryPath, values: url.Values{"timeScope": {"all"}, "created_gte": {from}, "created_lte": {to}}, status: http.StatusOK}, + {name: "historical range complete export", path: auditExportPath, values: url.Values{"timeScope": {"range"}, "created_gte": {from}, "created_lte": {to}, "format": {"json"}}, status: http.StatusOK}, + {name: "all retained page keeps scan guard", path: auditQueryPath, values: url.Values{"timeScope": {"all"}}, status: http.StatusUnprocessableEntity}, + {name: "all retained export keeps scan guard", path: auditExportPath, values: url.Values{"timeScope": {"all"}, "format": {"json"}}, status: http.StatusUnprocessableEntity}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + fixture := newAuditUpstreamFixture(t) + fixture.applyTimeBounds = true + fixture.auditRecords = []map[string]any{ + auditTestRecord("at-from", from, "1p1", "1a1", "range.event", "TokenAuth", "included lower bound"), + auditTestRecord("inside", "2026-08-29T02:30:00Z", "1p1", "1a1", "range.event", "TokenAuth", "included range row"), + auditTestRecord("at-to", to, "1p1", "1a1", "range.event", "TokenAuth", "local exclusive upper bound"), + auditTestRecord("before-from", "2026-08-29T01:59:59Z", "1p1", "1a1", "range.event", "TokenAuth", "outside lower bound"), + auditTestRecord("forbidden-in-range", "2026-08-29T02:15:00Z", "1p9", "1a9", "forbidden.event", "TokenAuth", "must not leak"), + } + for index := 0; index <= auditMaximumScanRows; index++ { + fixture.auditRecords = append(fixture.auditRecords, + auditTestRecord("newer-"+strconv.Itoa(index), "2026-08-30T02:00:00Z", "1p1", "1a1", "newer.event", "TokenAuth", "outside historical upper bound")) + } + server := newAuditTestBroker(t, fixture) + response := performAuditRequest(t, server, testCase.path+"?"+testCase.values.Encode()) + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if response.StatusCode != testCase.status { + t.Fatalf("status=%d expected=%d: %s", response.StatusCode, testCase.status, body) + } + fixture.mu.Lock() + queries := append([]url.Values(nil), fixture.auditQueries...) + fixture.mu.Unlock() + if len(queries) == 0 { + t.Fatal("missing upstream audit query") + } + allTime := testCase.values.Get("created_gte") == "" + for _, values := range queries { + if allTime { + if values.Get("created_gte") != "" || values.Get("created_lte") != "" { + t.Fatalf("all retained time was incorrectly bounded: %#v", values) + } + } else if values.Get("created_gte") != from || values.Get("created_lte") != to { + t.Fatalf("historical candidate range was not bounded: %#v", values) + } + } + if allTime { + if !bytes.Contains(body, []byte("result_set_too_large")) { + t.Fatalf("all retained time lost the scan guard: %s", body) + } + return + } + if len(queries) != 1 { + t.Fatalf("newer rows caused an unnecessary full scan: %d queries", len(queries)) + } + if bytes.Contains(body, []byte("at-to")) || bytes.Contains(body, []byte("before-from")) || bytes.Contains(body, []byte("newer-")) || bytes.Contains(body, []byte("forbidden-in-range")) || bytes.Contains(body, []byte("forbidden.event")) || bytes.Contains(body, []byte("1a9")) { + t.Fatalf("out-of-range or unauthorized data escaped: %s", body) + } + if testCase.path == auditExportPath { + var exported auditJSONExport + if err := json.Unmarshal(body, &exported); err != nil || exported.Count != 2 || len(exported.Records) != 2 || exported.Range != "[from,to)" || bytes.Contains(body, []byte("must-not-export")) { + t.Fatalf("historical export lost completeness or safety: %s %v", body, err) + } + } else { + var collection auditCollection + if err := json.Unmarshal(body, &collection); err != nil || len(collection.Data) != 2 || collection.Pagination["total"] != float64(2) { + t.Fatalf("historical page lost its exact total: %s %v", body, err) + } + } + }) + } +} + +func TestAuditPaginationPreservesInitialConditionsAndScopeAbsence(t *testing.T) { + from, to := "2026-08-29T02:00:00Z", "2026-08-29T03:00:00Z" + cases := []struct { + name string + values url.Values + next string + viewer string + }{ + {name: "generic environment range omitted by next", values: url.Values{"created_gte": {from}, "created_lte": {to}, "accountId": {"1p1"}}, next: "page=2"}, + {name: "verified project range cannot be replaced by next", values: url.Values{"created_gte": {from}, "created_lte": {to}, "accountId": {"1p1"}, "projectId": {"1p1"}}, next: "page=2&created_gte=2000-01-01T00%3A00%3A00Z&created_lte=2100-01-01T00%3A00%3A00Z&accountId=1p9&projectId=1p9&limit=250&sort=created&order=asc", viewer: "1a1"}, + {name: "all retained generic scope remains absent", values: url.Values{}, next: "page=2&created_gte=2000-01-01T00%3A00%3A00Z&created_lte=2100-01-01T00%3A00%3A00Z&accountId=1p9&projectId=1p9"}, + {name: "verified personal scope cannot gain a project", values: url.Values{"accountId": {"1a1"}}, next: "page=2&created_gte=2000-01-01T00%3A00%3A00Z&created_lte=2100-01-01T00%3A00%3A00Z&accountId=1p9&projectId=1p9", viewer: "1a1"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + fixture := newAuditUpstreamFixture(t) + fixture.nextAuditQuery, fixture.viewerAccountID = testCase.next, testCase.viewer + fixture.auditRecords = []map[string]any{auditTestRecord("first", "2026-08-29T02:30:00Z", "1p1", "1a1", "range.event", "TokenAuth", "first page")} + fixture.secondPage = []map[string]any{auditTestRecord("second", "2026-08-29T02:20:00Z", "1p1", "1a1", "range.event", "TokenAuth", "second page")} + cfg := brokerConfig{ + ListenAddress: ":0", UpstreamURL: fixture.server.URL, SessionDialURL: fixture.server.URL, + MaxSessions: 8, ReplayBytes: 128 * 1024, ActiveTTL: time.Hour, HistoryTTL: time.Hour, CleanupInterval: time.Hour, + } + instance, err := newBroker(cfg, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { instance.close() }) + values := testCase.values + values.Set("limit", "1000") + values.Set("sort", "id") + values.Set("order", "desc") + initial := values.Encode() + incoming := httptest.NewRequest(http.MethodGet, auditQueryPath, nil) + incoming.Header.Set("Cookie", fixture.allowedCookie) + records, err := instance.fetchAllAuditLogsForViewer(context.Background(), incoming, values, testCase.viewer) + if err != nil || len(records) != 2 { + t.Fatalf("pagination did not read both pages: count=%d error=%v", len(records), err) + } + if values.Encode() != initial { + t.Fatalf("pagination mutated its initial query: %q -> %q", initial, values.Encode()) + } + fixture.mu.Lock() + queries := append([]url.Values(nil), fixture.auditQueries...) + fixture.mu.Unlock() + if len(queries) != 2 || queries[1].Get("page") != "2" { + t.Fatalf("pagination cursor was not retained: %#v", queries) + } + for _, name := range []string{"created_gte", "created_lte", "accountId", "projectId", "limit", "sort", "order"} { + if queries[1].Get(name) != values.Get(name) { + t.Fatalf("next changed immutable %s: got=%q expected=%q", name, queries[1].Get(name), values.Get(name)) + } + } + }) + } +} + +func TestAuditBoundedRangeKeepsScanAndExportLimits(t *testing.T) { + for _, testCase := range []struct { + name string + path string + count int + code string + }{ + {name: "matching scan overflow", path: auditQueryPath, count: auditMaximumScanRows + 1, code: "result_set_too_large"}, + {name: "matching export overflow", path: auditExportPath, count: auditMaximumExportRows + 1, code: "export_too_large"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := newAuditUpstreamFixture(t) + fixture.applyTimeBounds = true + for index := 0; index < testCase.count; index++ { + fixture.auditRecords = append(fixture.auditRecords, + auditTestRecord("inside-"+strconv.Itoa(index), "2026-08-29T02:30:00Z", "1p1", "1a1", "range.event", "TokenAuth", "inside bounded range")) + } + server := newAuditTestBroker(t, fixture) + values := url.Values{"timeScope": {"range"}, "created_gte": {"2026-08-29T02:00:00Z"}, "created_lte": {"2026-08-29T03:00:00Z"}, "format": {"json"}} + response := performAuditRequest(t, server, testCase.path+"?"+values.Encode()) + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if response.StatusCode != http.StatusUnprocessableEntity || !bytes.Contains(body, []byte(testCase.code)) { + t.Fatalf("bounded query lost its %s guard: %d %s", testCase.code, response.StatusCode, body) + } + }) } } diff --git a/server/console-broker/broker.go b/server/console-broker/broker.go index 8a9289ddfa1..2d1a2a59af7 100644 --- a/server/console-broker/broker.go +++ b/server/console-broker/broker.go @@ -24,26 +24,34 @@ import ( ) const ( - sessionPathPrefix = "/v1/exec/sessions/" - healthPath = "/v1/exec/sessions/healthz" - consoleSubprotocol = "pasturestack-console-v1" - secretProtocolPrefix = "pasturestack-secret." - clientProtocolPrefix = "pasturestack-client." - maxCreateBody = 128 * 1024 - maxClientFrame = 96 * 1024 - maxUpstreamFrame = 4 * 1024 * 1024 - clientQueueSize = 256 - writeWait = 10 * time.Second - pongWait = 60 * time.Second - pingPeriod = 25 * time.Second - defaultDialAttempts = 3 - defaultDialRetryWait = 5 * time.Second + sessionPathPrefix = "/v1/exec/sessions/" + healthPath = "/v1/exec/sessions/healthz" + consoleSubprotocol = "pasturestack-console-v1" + secretProtocolPrefix = "pasturestack-secret." + clientProtocolPrefix = "pasturestack-client." + maxCreateBody = 128 * 1024 + maxClientFrame = 96 * 1024 + maxUpstreamFrame = 4 * 1024 * 1024 + clientQueueSize = 256 + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = 25 * time.Second + defaultDialAttempts = 3 + defaultDialRetryWait = 5 * time.Second + streamErrorHeader = "X-PastureStack-Stream-Error-Code" + routeErrorHeader = "X-Api-Error-Code" + backendAuditUnavailable = "BackendAuditCapabilityUnavailable" + streamAuditUnavailable = "AuditUnavailable" + streamRouteDenied = "DelegationRouteDenied" ) var ( - sessionIDPattern = regexp.MustCompile(`^psw_[A-Za-z0-9_-]{20,96}$`) - clientIDPattern = regexp.MustCompile(`^tab_[A-Za-z0-9_-]{20,96}$`) - secretPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{40,256}$`) + sessionIDPattern = regexp.MustCompile(`^psw_[A-Za-z0-9_-]{20,96}$`) + clientIDPattern = regexp.MustCompile(`^tab_[A-Za-z0-9_-]{20,96}$`) + secretPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{40,256}$`) + errBackendAuditUnavailable = errors.New(backendAuditUnavailable) + errStreamAuditUnavailable = errors.New(streamAuditUnavailable) + errStreamRouteDenied = errors.New(streamRouteDenied) ) type brokerConfig struct { @@ -246,6 +254,11 @@ func (b *broker) ServeHTTP(writer http.ResponseWriter, request *http.Request) { return } + if isKeyAuditPath(request.URL.Path) { + b.serveKeyAudit(writer, request) + return + } + if request.URL.Path == auditQueryPath || request.URL.Path == auditExportPath { b.serveAudit(writer, request) return @@ -333,7 +346,27 @@ func (b *broker) createSession(writer http.ResponseWriter, request *http.Request headers.Set("Origin", sessionDialOrigin(target)) upstream, err := b.dialUpstreamSession(request.Context(), target, headers) if err != nil { - b.logger.Printf("upstream session connection failed for %s: %s", safeLogValue(sessionID), safeLogValue(err)) + // A dial error can contain the signed ticket in its URL. Log only a + // fixed category, never the error, target, headers, or response body. + if errors.Is(err, errStreamRouteDenied) { + b.logger.Printf("upstream session connection failed for %s: %s", safeLogValue(sessionID), streamRouteDenied) + writer.Header().Set(routeErrorHeader, streamRouteDenied) + writeJSONError(writer, http.StatusForbidden, streamRouteDenied, "The signed stream request does not match its allowed route") + return + } + blockedCode := "" + if errors.Is(err, errBackendAuditUnavailable) { + blockedCode = backendAuditUnavailable + } else if errors.Is(err, errStreamAuditUnavailable) { + blockedCode = streamAuditUnavailable + } + if blockedCode != "" { + b.logger.Printf("upstream session connection failed for %s: %s", safeLogValue(sessionID), blockedCode) + writer.Header().Set(streamErrorHeader, blockedCode) + writeJSONError(writer, http.StatusServiceUnavailable, blockedCode, "Durable API Key stream auditing is unavailable") + return + } + b.logger.Printf("upstream session connection failed for %s: upstream_unavailable", safeLogValue(sessionID)) writeJSONError(writer, http.StatusBadGateway, "upstream_unavailable", "Unable to start the console session") return } @@ -381,8 +414,21 @@ func (b *broker) dialUpstreamSession(ctx context.Context, target *url.URL, heade for attempt := 1; attempt <= b.config.SessionDialAttempts; attempt++ { connection, response, err := websocket.DefaultDialer.DialContext(ctx, target.String(), headers) status := 0 + var blockedStreamError error if response != nil { status = response.StatusCode + // Only the operator-controlled internal dial endpoint can supply + // this signal. Browser headers and upstream bodies are not trusted. + if status == http.StatusServiceUnavailable { + switch response.Header.Get(streamErrorHeader) { + case backendAuditUnavailable: + blockedStreamError = errBackendAuditUnavailable + case streamAuditUnavailable: + blockedStreamError = errStreamAuditUnavailable + } + } else if status == http.StatusForbidden && response.Header.Get(routeErrorHeader) == streamRouteDenied { + blockedStreamError = errStreamRouteDenied + } if response.Body != nil { _ = response.Body.Close() } @@ -390,6 +436,9 @@ func (b *broker) dialUpstreamSession(ctx context.Context, target *url.URL, heade if err == nil { return connection, nil } + if blockedStreamError != nil { + return nil, blockedStreamError + } lastErr = err if status != http.StatusUnauthorized || attempt == b.config.SessionDialAttempts { break diff --git a/server/console-broker/broker_stream_error_test.go b/server/console-broker/broker_stream_error_test.go new file mode 100644 index 00000000000..84a5ea90f4c --- /dev/null +++ b/server/console-broker/broker_stream_error_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestSessionCreationPreservesOnlyTrustedStreamFailureContract(t *testing.T) { + for _, testCase := range []struct { + name string + status int + upstreamHeader string + upstreamCode string + wantStatus int + wantCode string + }{ + {"verified backend capability block", http.StatusServiceUnavailable, streamErrorHeader, backendAuditUnavailable, http.StatusServiceUnavailable, backendAuditUnavailable}, + {"durable failure receipt unavailable", http.StatusServiceUnavailable, streamErrorHeader, streamAuditUnavailable, http.StatusServiceUnavailable, streamAuditUnavailable}, + {"client header cannot upgrade generic 503", http.StatusServiceUnavailable, streamErrorHeader, "", http.StatusBadGateway, "upstream_unavailable"}, + {"matching code without 503 is not the contract", http.StatusForbidden, streamErrorHeader, backendAuditUnavailable, http.StatusBadGateway, "upstream_unavailable"}, + {"unknown upstream code is not reflected", http.StatusServiceUnavailable, streamErrorHeader, "private-upstream-error", http.StatusBadGateway, "upstream_unavailable"}, + {"signed route denial remains 403", http.StatusForbidden, routeErrorHeader, streamRouteDenied, http.StatusForbidden, streamRouteDenied}, + {"client header cannot upgrade generic 403", http.StatusForbidden, routeErrorHeader, "", http.StatusBadGateway, "upstream_unavailable"}, + {"unknown route code is not reflected", http.StatusForbidden, routeErrorHeader, "private-upstream-error", http.StatusBadGateway, "upstream_unavailable"}, + {"route code without 403 is not the contract", http.StatusServiceUnavailable, routeErrorHeader, streamRouteDenied, http.StatusBadGateway, "upstream_unavailable"}, + } { + t.Run(testCase.name, func(t *testing.T) { + const ticket = "signed-ticket-never-log-this-123456789" + const backendTicket = "backend-ticket-never-reflect-this" + const secret = "3023456789abcdefghijklmnopqrstuvwxyzABCDEFGH" + var attempts atomic.Int32 + var forwardedClientSignal atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + attempts.Add(1) + if request.Header.Get(streamErrorHeader) != "" || request.Header.Get(routeErrorHeader) != "" || request.Header.Get("Authorization") != "" || request.Header.Get("Cookie") != "" { + forwardedClientSignal.Store(true) + } + if request.URL.Query().Get("token") != ticket { + t.Error("the actual upstream handshake lost its ticket") + } + writer.Header().Set(testCase.upstreamHeader, testCase.upstreamCode) + writer.WriteHeader(testCase.status) + _, _ = io.WriteString(writer, ticket+" "+backendTicket) + })) + defer upstream.Close() + var logs bytes.Buffer + instance, err := newBroker(brokerConfig{ + UpstreamURL: upstream.URL, SessionDialURL: upstream.URL, + MaxSessions: 8, CleanupInterval: time.Hour, + SessionDialRetryWait: time.Millisecond, + }, log.New(&logs, "", 0)) + if err != nil { + t.Fatal(err) + } + defer instance.close() + server := httptest.NewServer(instance) + defer server.Close() + body, err := json.Marshal(createSessionRequest{ + Secret: secret, Kind: "logs", + Target: "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/exec?private=" + backendTicket, + Token: ticket, + }) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodPost, server.URL+sessionPathPrefix+"psw_abcdefghijklmnopqrstuv03", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set(streamErrorHeader, backendAuditUnavailable) + request.Header.Set(routeErrorHeader, streamRouteDenied) + request.Header.Set("Authorization", "Bearer "+backendTicket) + request.Header.Set("Cookie", "session="+backendTicket) + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + responseBody, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + var frame errorFrame + if err := json.Unmarshal(responseBody, &frame); err != nil { + t.Fatal(err) + } + if response.StatusCode != testCase.wantStatus || frame.Code != testCase.wantCode { + t.Fatalf("unexpected handshake response: status=%d code=%s", response.StatusCode, frame.Code) + } + wantHeader := "" + if testCase.wantStatus == http.StatusServiceUnavailable { + wantHeader = testCase.wantCode + } + if response.Header.Get(streamErrorHeader) != wantHeader || response.Header.Get("Cache-Control") != "no-store" { + t.Fatal("handshake response lost its fixed safe header/cache contract") + } + wantRouteHeader := "" + if testCase.wantStatus == http.StatusForbidden { + wantRouteHeader = streamRouteDenied + } + if response.Header.Get(routeErrorHeader) != wantRouteHeader { + t.Fatal("route denial response lost its fixed safe header contract") + } + if attempts.Load() != 1 || instance.activeSessionCount() != 0 || forwardedClientSignal.Load() { + t.Fatal("a blocked handshake retried, created a session, or trusted browser metadata") + } + for _, sensitive := range []string{ticket, backendTicket, secret, "private-upstream-error"} { + if strings.Contains(string(responseBody), sensitive) || strings.Contains(logs.String(), sensitive) { + t.Fatal("handshake response or logger retained private upstream material") + } + } + }) + } +} diff --git a/server/console-broker/keyaudit.go b/server/console-broker/keyaudit.go new file mode 100644 index 00000000000..89c8949bfe9 --- /dev/null +++ b/server/console-broker/keyaudit.go @@ -0,0 +1,271 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "regexp" + "sort" + "strings" + "time" +) + +const keyAuditQueryPath = "/v2-beta/pasturestack/key-audit-logs" + +var keyAuditIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,159}$`) + +func (b *broker) fetchKeyAuditLogs(ctx context.Context, incoming *http.Request, query auditQuery, + allowedProjects map[string]string, baseValues url.Values) ([]map[string]any, error) { + // Only Engine-proven live memberships select project contexts. Never + // accept a client project header, inferred Key owner or payload as a grant. + contexts := make([]string, 0, len(allowedProjects)+1) + if query.EnvironmentID == "" { + contexts = append(contexts, "") // current viewer's personal account + } + for projectID := range allowedProjects { + if !keyAuditIDPattern.MatchString(projectID) { + return nil, &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_key_audit_scope", Message: "Current environment permissions could not be verified"} + } + if query.EnvironmentID == "" || projectID == query.EnvironmentID { + contexts = append(contexts, projectID) + } + } + sort.Strings(contexts) + seen := make(map[string]bool) + records := make([]map[string]any, 0) + scanned := 0 + for _, projectID := range contexts { + values := make(url.Values, len(baseValues)+2) + for name, items := range baseValues { + values[name] = append([]string(nil), items...) + } + values.Del("projectId") + accountID := query.ViewerAccountID + if projectID != "" { + values.Set("projectId", projectID) + accountID = projectID + } + values.Set("accountId", accountID) + pageRecords, err := b.fetchAllAuditLogsForViewer(ctx, incoming, values, query.ViewerAccountID) + if err != nil { + return nil, err // role/session loss is fail-closed, never partial data + } + scanned += len(pageRecords) + if scanned > auditMaximumScanRows { + return nil, &auditHTTPError{Status: http.StatusUnprocessableEntity, Code: "result_set_too_large", Message: "Narrow the audit log time range or environment before continuing"} + } + for _, record := range pageRecords { + id := auditString(record, "id") + if !keyAuditIDPattern.MatchString(id) { + return nil, &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_audit_response", Message: "Audit log service returned an invalid event identifier"} + } + if !seen[id] { + seen[id] = true + records = append(records, record) + } + } + } + return records, nil +} + +func isKeyAuditPath(path string) bool { + return path == keyAuditQueryPath || strings.HasPrefix(path, keyAuditQueryPath+"/") +} + +func (b *broker) serveKeyAudit(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", "GET") + writeJSONError(writer, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") + return + } + keyID := request.URL.Query().Get("keyId") + if !keyAuditIDPattern.MatchString(keyID) { + writeJSONError(writer, http.StatusBadRequest, "key_id_required", "A valid API key identifier is required") + return + } + values := request.URL.Query() + // The generic audit contract uses timeScope (v518). Accept the early + // Key UI alias only on this new route, without changing generic parsing. + if alias := strings.ToLower(values.Get("timescope")); alias != "" { + if alias != "all" && alias != "range" { + writeJSONError(writer, http.StatusBadRequest, "invalid_timescope", "Audit time scope must be all or range") + return + } + if canonical := values.Get("timeScope"); canonical != "" && (alias != "all" || canonical != "all") { + writeJSONError(writer, http.StatusBadRequest, "conflicting_time_scope", "Conflicting audit time scopes") + return + } + if alias == "all" { + values.Set("timeScope", "all") + } + values.Del("timescope") + } + detailID := strings.TrimPrefix(request.URL.Path, keyAuditQueryPath) + if detailID != "" { + detailID = strings.TrimPrefix(detailID, "/") + if !keyAuditIDPattern.MatchString(detailID) { + writeJSONError(writer, http.StatusNotFound, "key_audit_not_found", "The API key audit event is not available") + return + } + // A detail is still scoped by live permissions and keyId, independent + // of a UI's selected time range or pagination. + values.Set("timeScope", "all") + for _, name := range []string{"created_gte", "created_lte", "createdFrom", "createdTo"} { + values.Del(name) + } + } + query, err := parseAuditQuery(values, time.Now().UTC()) + if err != nil { + writeAuditFailure(writer, err) + return + } + owner, viewer, err := b.fetchKeyAuditAuthority(request.Context(), request, keyID) + if err != nil { + writeAuditFailure(writer, err) + return + } + query.KeyID, query.KeyOwnerAccountID, query.ViewerAccountID = keyID, owner, viewer + result, err := b.runAuditQuery(request.Context(), request, query) + if err != nil { + writeAuditFailure(writer, err) + return + } + if detailID != "" { + for _, record := range result.Records { + if auditString(record, "id") == detailID { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("X-Content-Type-Options", "nosniff") + _ = json.NewEncoder(writer).Encode(record) + return + } + } + writeJSONError(writer, http.StatusNotFound, "key_audit_not_found", "The API key audit event is not available under your current permissions") + return + } + writeAuditCollection(writer, request, query, result) +} + +// Credential visibility and principal identity are determined by the Engine +// for the current session on every query. No browser identity is trusted. +func (b *broker) fetchKeyAuditAuthority(ctx context.Context, incoming *http.Request, keyID string) (string, string, error) { + owner, viewer, err := b.fetchKeyAuditAuthorityInProject(ctx, incoming, keyID, "", "") + if err == nil { + return owner, viewer, nil // Keep the existing personal/admin visibility fast path. + } + var upstreamErr *auditHTTPError + if !errors.As(err, &upstreamErr) || upstreamErr.Status != http.StatusForbidden || upstreamErr.Code != "key_audit_access_lost" { + return "", "", err // Never retry an expired session, outage, redirect or invalid response. + } + projects, principal, err := b.fetchAuditProjects(ctx, incoming, true) + if err != nil { + return "", "", err + } + contexts := make([]string, 0, len(projects)) + for id := range projects { + contexts = append(contexts, id) + } + sort.Strings(contexts) + for _, projectID := range contexts { + // The shared Engine enumeration only selects candidate scopes. The + // scoped credential GET rechecks live membership/object/schema access. + // Neither a caller project header nor an inferred owner is a grant. + owner, viewer, err = b.fetchKeyAuditAuthorityInProject(ctx, incoming, keyID, projectID, principal) + if err == nil { + return owner, viewer, nil + } + if !errors.As(err, &upstreamErr) || upstreamErr.Status != http.StatusForbidden || upstreamErr.Code != "key_audit_access_lost" { + return "", "", err + } + } + return "", "", &auditHTTPError{Status: http.StatusForbidden, Code: "key_audit_access_lost", Message: "The API key is no longer available under your current permissions"} +} + +func (b *broker) fetchKeyAuditAuthorityInProject(ctx context.Context, incoming *http.Request, keyID, projectID, principal string) (string, string, error) { + target := *b.upstreamURL + client := &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { + return errors.New("API key authority redirects are not allowed") + }} + for index, kind := range []string{"apiKey", "apiKeyRestricted"} { + target.Path, target.RawPath, target.RawQuery, target.Fragment = "/v2-beta/"+strings.ToLower(kind)+"/"+keyID, "", "", "" + if projectID != "" { + target.RawQuery = url.Values{"projectId": {projectID}}.Encode() + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return "", "", err + } + copyAuditAuthHeaders(request.Header, incoming.Header) + request.Header.Set("Accept", "application/json") + response, err := client.Do(request) + if err != nil { + return "", "", &auditHTTPError{Status: http.StatusBadGateway, Code: "key_audit_unavailable", Message: "API key permissions could not be verified"} + } + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) + response.Body.Close() + if response.StatusCode == http.StatusNotFound && index == 0 { + continue + } + status, code := http.StatusBadGateway, "key_audit_unavailable" + if response.StatusCode == http.StatusUnauthorized { + status, code = http.StatusUnauthorized, "key_audit_session_expired" + } else if response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusNotFound { + status, code = http.StatusForbidden, "key_audit_access_lost" + } + return "", "", &auditHTTPError{Status: status, Code: code, Message: "The API key is no longer available under your current permissions"} + } + var key map[string]any + decoder := json.NewDecoder(io.LimitReader(response.Body, 1024*1024)) + decodeErr := decoder.Decode(&key) + response.Body.Close() + if decodeErr != nil || auditString(key, "id") != keyID || !strings.EqualFold(auditString(key, "type"), kind) { + return "", "", &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_key_authority", Message: "API key service returned an invalid permission response"} + } + owner, viewer := auditString(key, "accountId"), response.Header.Get("X-API-USER-ID") + if !keyAuditIDPattern.MatchString(owner) || !keyAuditIDPattern.MatchString(viewer) { + return "", "", &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_key_authority", Message: "API key owner and current user could not be verified"} + } + if projectID != "" && (owner != projectID || viewer != principal || response.Header.Get("X-API-ACCOUNT-ID") != projectID) { + return "", "", &auditHTTPError{Status: http.StatusBadGateway, Code: "invalid_key_authority", Message: "API key environment and current user could not be verified"} + } + return owner, viewer, nil + } + return "", "", &auditHTTPError{Status: http.StatusForbidden, Code: "key_audit_access_lost", Message: "The API key is no longer available under your current permissions"} +} + +func keyAuditMetadataString(record map[string]any, field string) string { + if value := auditString(record, field); value != "" { + return value + } + if data, ok := record["data"].(map[string]any); ok { + if fields, ok := data["fields"].(map[string]any); ok { + return auditString(fields, field) + } + } + return "" +} + +func safeKeyAuditRecord(record map[string]any) map[string]any { + result := make(map[string]any) + for _, field := range []string{ + "type", "id", "created", "accountId", "authenticatedAsAccountId", "authenticatedAsIdentityId", + "authType", "eventType", "resourceType", "resourceId", "clientIp", "runtime", + "eventId", "keyId", "decision", "outcome", "httpStatus", "responseCode", "requestId", "actor", + "targetType", "targetId", "operation", "policyRevision", "reason", "phase", "preview", "processId", "processName", + "hostUuid", "failureCode", + } { + if value, present := record[field]; present { + switch value.(type) { + case string, bool, json.Number, float64, int, int64: + result[field] = value + } + } else if value := keyAuditMetadataString(record, field); value != "" { + result[field] = value + } + } + return result +} diff --git a/server/console-broker/keyaudit_test.go b/server/console-broker/keyaudit_test.go new file mode 100644 index 00000000000..2082a00e3af --- /dev/null +++ b/server/console-broker/keyaudit_test.go @@ -0,0 +1,674 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "testing" +) + +type keyAuditFixture struct { + mu sync.Mutex + owner string + keyStatus int + restricted bool + viewer string + projects []map[string]any + records []map[string]any + auditQueries []url.Values + accountScoped bool + deniedProject string + projectPagination bool + projectAuthorityNext bool + requireProjectKey bool + keyQueries []url.Values + keyProjectHeaders []string + projectQueries []url.Values + projectStatus int + projectRedirect bool + scopedKeyStatus int + scopedKeyRedirect bool + scopedKeyOverride map[string]string + loseProjectAfterKey bool + changeViewerAfterKey bool + auditRedirect bool + auditViewer string + redirectHits int +} + +func newKeyAuditFixture(t *testing.T) (*keyAuditFixture, *httptest.Server) { + t.Helper() + fixture := &keyAuditFixture{ + owner: "1a1", viewer: "1a1", keyStatus: http.StatusOK, + projects: []map[string]any{{"type": "project", "id": "1p1", "name": "Production"}}, + } + upstream := &auditUpstreamFixture{} + upstream.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Cookie") != "R_SESS=authorized" { + http.Error(writer, "unauthorized", http.StatusUnauthorized) + return + } + fixture.mu.Lock() + defer fixture.mu.Unlock() + writer.Header().Set("X-API-USER-ID", fixture.viewer) + switch request.URL.Path { + case "/v2-beta/apikey/1a99", "/v2-beta/apikeyrestricted/1a99": + fixture.keyQueries = append(fixture.keyQueries, request.URL.Query()) + fixture.keyProjectHeaders = append(fixture.keyProjectHeaders, request.Header.Get("X-API-Project-ID")) + kind := "apiKey" + if strings.Contains(request.URL.Path, "apikeyrestricted") { + if !fixture.restricted { + http.NotFound(writer, request) + return + } + kind = "apiKeyRestricted" + } else if fixture.restricted { + http.NotFound(writer, request) + return + } + projectID := request.URL.Query().Get("projectId") + if fixture.requireProjectKey && projectID != fixture.owner { + http.Error(writer, "credential outside account policy", http.StatusForbidden) + return + } + if fixture.requireProjectKey { + writer.Header().Set("X-API-ACCOUNT-ID", projectID) + if fixture.scopedKeyRedirect { + http.Redirect(writer, request, "/authority-redirect", http.StatusFound) + return + } + if fixture.scopedKeyStatus != 0 { + http.Error(writer, "scope unavailable", fixture.scopedKeyStatus) + return + } + } + key := map[string]any{"type": kind, "id": "1a99", "accountId": fixture.owner, "secretValue": "never-return-key-secret"} + for field, value := range fixture.scopedKeyOverride { + switch field { + case "viewer": + writer.Header().Set("X-API-USER-ID", value) + case "context": + writer.Header().Set("X-API-ACCOUNT-ID", value) + default: + key[field] = value + } + } + writer.WriteHeader(fixture.keyStatus) + _ = json.NewEncoder(writer).Encode(key) + if fixture.loseProjectAfterKey { + fixture.projects = nil + } + if fixture.changeViewerAfterKey { + fixture.viewer = "1a2" + } + case "/v2-beta/projects": + fixture.projectQueries = append(fixture.projectQueries, request.URL.Query()) + if fixture.projectRedirect { + http.Redirect(writer, request, "/authority-redirect", http.StatusFound) + return + } + if fixture.projectStatus != 0 { + http.Error(writer, "projects unavailable", fixture.projectStatus) + return + } + next := "" + if fixture.projectAuthorityNext { + next = upstream.server.URL + "/v2-beta/projects?marker=more" + } + writeFixtureCollection(writer, fixture.projects, next) + case "/v2-beta/accounts": + writeFixtureCollection(writer, []map[string]any{{"type": "account", "id": "1a1", "name": "Owner"}}, "") + case "/v2-beta/auditlogs": + fixture.auditQueries = append(fixture.auditQueries, request.URL.Query()) + if fixture.auditViewer != "" { + writer.Header().Set("X-API-USER-ID", fixture.auditViewer) + } + if fixture.auditRedirect { + http.Redirect(writer, request, "/authority-redirect", http.StatusFound) + return + } + if !fixture.accountScoped { + writeFixtureCollection(writer, fixture.records, "") + break + } + projectID := request.URL.Query().Get("projectId") + if projectID != "" && projectID == fixture.deniedProject { + http.Error(writer, "forbidden", http.StatusForbidden) + break + } + accountID := fixture.viewer + if projectID != "" { + accountID = projectID + } + var records []map[string]any + for _, record := range fixture.records { + if auditString(record, "accountId") == accountID { + records = append(records, record) + } + } + next := "" + if projectID != "" && fixture.projectPagination && request.URL.Query().Get("marker") == "" && len(records) > 1 { + records = records[:1] + // Real Engine next URLs can omit request-local project context. + next = upstream.server.URL + "/v2-beta/auditlogs?marker=second" + } else if request.URL.Query().Get("marker") == "second" && len(records) > 1 { + records = records[1:] + } + writeFixtureCollection(writer, records, next) + case "/authority-redirect": + fixture.redirectHits++ + http.Error(writer, "must not follow", http.StatusServiceUnavailable) + default: + http.NotFound(writer, request) + } + })) + t.Cleanup(upstream.server.Close) + return fixture, newAuditTestBroker(t, upstream) +} + +func keyAuditRecord(id, keyID, project, created string) map[string]any { + record := auditTestRecord(id, created, project, "1a1", "api.stack.read", "BasicAuth", "payload-must-not-leak") + for key, value := range map[string]any{ + "keyId": keyID, "decision": "ALLOW", "outcome": "SUCCEEDED", "httpStatus": 200, + "operation": "read", "requestId": "request-123", "targetType": "stack", "targetId": "1st1", + "policyRevision": 3, "phase": "response", "preview": false, + } { + record[key] = value + } + record["Authorization"] = "Bearer-secret" + record["responseObject"] = map[string]any{"Cookie": "session-secret", "jwt": "jwt-secret", "oidcCode": "code-secret"} + return record +} + +func TestKeyAuditFiltersBeforeCountAndPaginationAndAllTimeIncludesOldReads(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.records = []map[string]any{ + keyAuditRecord("event1", "1a99", "1p1", "2020-01-01T00:00:00Z"), + keyAuditRecord("event2", "1a99", "1p1", "2021-01-01T00:00:00Z"), + keyAuditRecord("foreign-key", "1a98", "1p1", "2022-01-01T00:00:00Z"), + keyAuditRecord("hidden-project", "1a99", "1p9", "2023-01-01T00:00:00Z"), + } + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99×cope=all&limit=1&offset=1&order=asc") + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("key query status %d", response.StatusCode) + } + bytes, _ := io.ReadAll(response.Body) + var payload auditCollection + if err := json.Unmarshal(bytes, &payload); err != nil { + t.Fatal(err) + } + if len(payload.Data) != 1 || auditString(payload.Data[0], "id") != "event2" || payload.Pagination["total"] != float64(2) { + t.Fatalf("count/pagination included unauthorized rows: %s", bytes) + } + for _, secret := range []string{"Bearer-secret", "session-secret", "jwt-secret", "code-secret", "payload-must-not-leak", "never-return-key-secret", "foreign-key", "hidden-project"} { + if strings.Contains(string(bytes), secret) { + t.Fatalf("unsafe query response retained %s", secret) + } + } + if len(fixture.auditQueries) != 2 || fixture.auditQueries[0].Get("created_gte") != "" || fixture.auditQueries[1].Get("created_gte") != "" { + t.Fatalf("all-time query kept a lower time boundary: %#v", fixture.auditQueries) + } +} + +func TestKeyAuditReadonlyOwnKeyUsesLiveProjectContextBeforeCountAndDetail(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.accountScoped = true + fixture.records = []map[string]any{ + keyAuditRecord("personal", "1a99", "1a1", "2020-01-01T00:00:00Z"), + keyAuditRecord("decision", "1a99", "1p1", "2021-01-01T00:00:00Z"), + keyAuditRecord("response405", "1a99", "1p1", "2022-01-01T00:00:00Z"), + keyAuditRecord("hidden", "1a99", "1p9", "2023-01-01T00:00:00Z"), + keyAuditRecord("foreign", "1a98", "1p1", "2024-01-01T00:00:00Z"), + } + fixture.records[2]["outcome"], fixture.records[2]["httpStatus"] = "FAILED", 405 + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99&timeScope=all&limit=1&offset=1&order=asc") + defer response.Body.Close() + var payload auditCollection + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || payload.Pagination["total"] != float64(3) || len(payload.Data) != 1 || auditString(payload.Data[0], "id") != "decision" { + t.Fatalf("live project rows missing before count/pagination: %d %#v", response.StatusCode, payload) + } + detail := performAuditRequest(t, server, keyAuditQueryPath+"/response405?keyId=1a99") + defer detail.Body.Close() + if detail.StatusCode != http.StatusOK { + t.Fatalf("own denied update event missing: %d", detail.StatusCode) + } + for _, id := range []string{"foreign", "hidden"} { + hidden := performAuditRequest(t, server, keyAuditQueryPath+"/"+id+"?keyId=1a99") + hidden.Body.Close() + if hidden.StatusCode != http.StatusNotFound { + t.Fatalf("foreign/currently inaccessible event escaped: %s %d", id, hidden.StatusCode) + } + } +} + +func TestKeyAuditProjectContextSurvivesPaginationAndRoleLossFailsClosed(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.accountScoped, fixture.projectPagination = true, true + fixture.records = []map[string]any{ + keyAuditRecord("decision", "1a99", "1p1", "2021-01-01T00:00:00Z"), + keyAuditRecord("response", "1a99", "1p1", "2022-01-01T00:00:00Z"), + } + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99&timeScope=all") + defer response.Body.Close() + var payload auditCollection + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || payload.Pagination["total"] != float64(2) { + t.Fatalf("later page lost verified project context: %d %#v", response.StatusCode, payload) + } + fixture.mu.Lock() + fixture.deniedProject = "1p1" + fixture.mu.Unlock() + lost := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99&timeScope=all") + defer lost.Body.Close() + if lost.StatusCode != http.StatusForbidden { + t.Fatalf("mid-query live role loss returned partial collection: %d", lost.StatusCode) + } +} + +func TestKeyAuditDoesNotTrustCallerProjectHeaderOrReturnForeignPersonalKey(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.accountScoped = true + fixture.records = []map[string]any{keyAuditRecord("hidden", "1a99", "1p9", "2020-01-01T00:00:00Z")} + request, _ := http.NewRequest(http.MethodGet, server.URL+keyAuditQueryPath+"?keyId=1a99&timeScope=all", nil) + request.Header.Set("Cookie", "R_SESS=authorized") + request.Header.Set("X-API-Project-ID", "1p9") + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var payload auditCollection + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || payload.Pagination["total"] != float64(0) { + t.Fatalf("caller project header was treated as a grant: %d %#v", response.StatusCode, payload) + } + fixture.mu.Lock() + fixture.owner = "1a2" + fixture.mu.Unlock() + foreign := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99&timeScope=all") + defer foreign.Body.Close() + if foreign.StatusCode != http.StatusForbidden { + t.Fatalf("foreign personal key ceiling relaxed: %d", foreign.StatusCode) + } +} + +func TestKeyAuditCombinedProjectScanBoundCannotBeBypassed(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.accountScoped = true + fixture.projects = []map[string]any{{"type": "project", "id": "1p1"}, {"type": "project", "id": "1p2"}} + for index := 0; index <= auditMaximumScanRows; index++ { + account := "1p1" + if index%2 != 0 { + account = "1p2" + } + fixture.records = append(fixture.records, map[string]any{ + "id": "event" + strconv.Itoa(index), "accountId": account, + "keyId": "1a99", "created": "2020-01-01T00:00:00Z", + }) + } + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99&timeScope=all") + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if response.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(string(body), "result_set_too_large") { + t.Fatalf("combined contexts exceeded global scan bound: %d", response.StatusCode) + } +} + +func TestKeyAuditIncompleteLiveProjectAuthorityNeverReturnsPartialCounts(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.projectAuthorityNext = true + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99&timeScope=all") + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if response.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(string(body), "result_set_too_large") { + t.Fatalf("partial project authority claimed complete Key audit results: %d", response.StatusCode) + } + if len(fixture.auditQueries) != 0 { + t.Fatal("partial authority was used for an audit scan") + } +} + +func TestKeyAuditDetailCannotEscapeKeyOrCurrentProjectPermissions(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.records = []map[string]any{ + keyAuditRecord("allowed", "1a99", "1p1", "2020-01-01T00:00:00Z"), + keyAuditRecord("foreign", "1a98", "1p1", "2020-01-01T00:00:00Z"), + keyAuditRecord("hidden", "1a99", "1p9", "2020-01-01T00:00:00Z"), + } + for _, id := range []string{"foreign", "hidden"} { + response := performAuditRequest(t, server, keyAuditQueryPath+"/"+id+"?keyId=1a99") + response.Body.Close() + if response.StatusCode != http.StatusNotFound { + t.Fatalf("detail %s bypassed current scope: %d", id, response.StatusCode) + } + } + response := performAuditRequest(t, server, keyAuditQueryPath+"/allowed?keyId=1a99") + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("historical authorized detail unavailable: %d", response.StatusCode) + } + fixture.mu.Lock() + fixture.projects = nil + fixture.mu.Unlock() + lost := performAuditRequest(t, server, keyAuditQueryPath+"/allowed?keyId=1a99") + defer lost.Body.Close() + if lost.StatusCode != http.StatusNotFound { + t.Fatalf("detail reused stale RBAC after project removal: %d", lost.StatusCode) + } +} + +func TestKeyAuditDeniesForeignPersonalKeyAndReportsLostEnvironmentAccess(t *testing.T) { + for _, owner := range []string{"1a2", "1p9"} { + t.Run(owner, func(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.owner = owner + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99×cope=all") + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if response.StatusCode != http.StatusForbidden || !strings.Contains(string(body), "key_audit_access_lost") { + t.Fatalf("foreign key owner was allowed: %d %s", response.StatusCode, body) + } + if len(fixture.auditQueries) != 0 { + t.Fatal("unauthorized key lookup triggered an audit scan") + } + }) + } +} + +func TestKeyAuditLostCredentialAccessDoesNotUseCachedAuthorization(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99×cope=all") + response.Body.Close() + fixture.mu.Lock() + fixture.keyStatus = http.StatusNotFound + fixture.mu.Unlock() + lost := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99×cope=all") + defer lost.Body.Close() + body, _ := io.ReadAll(lost.Body) + if lost.StatusCode != http.StatusForbidden || !strings.Contains(string(body), "key_audit_access_lost") { + t.Fatalf("removed key reused stale authorization: %d %s", lost.StatusCode, body) + } +} + +func TestKeyAuditRequiresKeyIDAndSupportsResultFilters(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.records = []map[string]any{keyAuditRecord("success", "1a99", "1p1", "2020-01-01T00:00:00Z")} + missing := performAuditRequest(t, server, keyAuditQueryPath+"?timescope=all") + missing.Body.Close() + if missing.StatusCode != http.StatusBadRequest { + t.Fatalf("missing key scope accepted: %d", missing.StatusCode) + } + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99×cope=all&outcome=FAILED") + defer response.Body.Close() + var payload auditCollection + if json.NewDecoder(response.Body).Decode(&payload) != nil || len(payload.Data) != 0 || payload.Pagination["total"] != float64(0) { + t.Fatal("actual outcome filter was not applied before count") + } +} + +func TestKeyAuditKeepsV518TimeScopeContractAndSupportsOnlyKeyRouteAlias(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.records = []map[string]any{keyAuditRecord("historical", "1a99", "1p1", "2020-01-01T00:00:00Z")} + for _, parameter := range []string{"timeScope=all", "timescope=all"} { + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99&"+parameter) + var payload auditCollection + err := json.NewDecoder(response.Body).Decode(&payload) + response.Body.Close() + if response.StatusCode != http.StatusOK || err != nil || len(payload.Data) != 1 { + t.Fatalf("historical Key audit query lost timeScope compatibility: %s status=%d err=%v", parameter, response.StatusCode, err) + } + } +} + +func TestKeyAuditRestrictedCredentialUsesSameStableKeyIdentifier(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.restricted = true + fixture.records = []map[string]any{keyAuditRecord("event1", "1a99", "1p1", "2020-01-01T00:00:00Z")} + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99×cope=all") + defer response.Body.Close() + var payload auditCollection + if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&payload) != nil || len(payload.Data) != 1 { + t.Fatal("restricted credential authority fallback lost the stable key ID") + } +} + +func TestKeyAuditTrustsEngineViewerIdentityAndFailsClosedWhenMissing(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.owner = "1a2" + request, _ := http.NewRequest(http.MethodGet, server.URL+keyAuditQueryPath+"?keyId=1a99×cope=all", nil) + request.Header.Set("Cookie", "R_SESS=authorized") + request.Header.Set("X-API-USER-ID", "1a2") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("browser-controlled actor header bypassed Engine principal: %d", response.StatusCode) + } + fixture.mu.Lock() + fixture.owner, fixture.viewer = "1a1", "" + fixture.mu.Unlock() + missing := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99×cope=all") + defer missing.Body.Close() + body, _ := io.ReadAll(missing.Body) + if missing.StatusCode != http.StatusBadGateway || !strings.Contains(string(body), "invalid_key_authority") { + t.Fatalf("missing trusted actor failed open: %d %s", missing.StatusCode, body) + } +} + +func TestKeyAuditUpstreamAuthenticationFailuresReturnStableCodes(t *testing.T) { + for status, code := range map[int]string{ + http.StatusUnauthorized: "key_audit_session_expired", + http.StatusForbidden: "key_audit_access_lost", + http.StatusServiceUnavailable: "key_audit_unavailable", + } { + t.Run(code, func(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.keyStatus = status + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99×cope=all") + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), code) || len(fixture.auditQueries) != 0 { + t.Fatalf("unverified authority started an audit query or returned an unclear error: %d %s", response.StatusCode, body) + } + }) + } +} + +func TestKeyAuditProjectOwnedCredentialUsesEngineProvenContextAndKeepsBaseline(t *testing.T) { + for _, restricted := range []bool{false, true} { + t.Run(strconv.FormatBool(restricted), func(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.owner, fixture.requireProjectKey, fixture.accountScoped = "1p1", true, true + fixture.restricted = restricted + fixture.records = []map[string]any{ + keyAuditRecord("baseline1", "1a99", "1p1", "2020-01-01T00:00:00Z"), + keyAuditRecord("baseline2", "1a99", "1p1", "2021-01-01T00:00:00Z"), + keyAuditRecord("personal", "1a99", "1a1", "2022-01-01T00:00:00Z"), + keyAuditRecord("hidden", "1a99", "1p9", "2023-01-01T00:00:00Z"), + } + request, _ := http.NewRequest(http.MethodGet, server.URL+keyAuditQueryPath+"?keyId=1a99&timeScope=all&limit=1&offset=1&order=asc", nil) + request.Header.Set("Cookie", "R_SESS=authorized") + request.Header.Set("X-API-Project-ID", "1p9") + request.Header.Set("X-API-USER-ID", "forged-viewer") + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var payload auditCollection + if json.NewDecoder(response.Body).Decode(&payload) != nil || response.StatusCode != http.StatusOK || + payload.Pagination["total"] != float64(2) || len(payload.Data) != 1 || auditString(payload.Data[0], "id") != "baseline2" { + t.Fatalf("project owner lost historical baseline/count: %d %#v", response.StatusCode, payload) + } + for index, query := range fixture.keyQueries { + if fixture.keyProjectHeaders[index] != "" || (query.Get("projectId") != "" && query.Get("projectId") != "1p1") { + t.Fatalf("client/unproven context forwarded: %#v %q", query, fixture.keyProjectHeaders[index]) + } + } + if len(fixture.projectQueries) != 2 || fixture.projectQueries[0].Get("all") != "true" || fixture.projectQueries[0].Get("limit") != "1000" { + t.Fatalf("missing complete live candidate/final authority reads: %#v", fixture.projectQueries) + } + detail := performAuditRequest(t, server, keyAuditQueryPath+"/baseline1?keyId=1a99") + detail.Body.Close() + if detail.StatusCode != http.StatusOK { + t.Fatalf("project-owned historical detail lost: %d", detail.StatusCode) + } + }) + } +} + +func TestKeyAuditProjectFallbackScopeGuardsFailBeforeDataScan(t *testing.T) { + cases := []struct { + name string + status int + setup func(*keyAuditFixture) + }{ + {"no-membership", http.StatusForbidden, func(f *keyAuditFixture) { f.projects = nil }}, + {"non-project", http.StatusForbidden, func(f *keyAuditFixture) { + f.projects = []map[string]any{{"type": "environment", "id": "1p1"}} + }}, + {"invalid-project-id", http.StatusBadGateway, func(f *keyAuditFixture) { + f.projects = []map[string]any{{"type": "project", "id": "../1p1"}} + }}, + {"non-string-project-id", http.StatusBadGateway, func(f *keyAuditFixture) { + f.projects = []map[string]any{{"type": "project", "id": 123}} + }}, + {"duplicate-project-id", http.StatusBadGateway, func(f *keyAuditFixture) { f.projects = append(f.projects, f.projects[0]) }}, + {"unbounded-projects", http.StatusUnprocessableEntity, func(f *keyAuditFixture) { + f.projects = nil + for index := 0; index <= auditUpstreamPageSize; index++ { + f.projects = append(f.projects, map[string]any{"type": "project", "id": "1p" + strconv.Itoa(index)}) + } + }}, + {"partial-projects", http.StatusUnprocessableEntity, func(f *keyAuditFixture) { f.projectAuthorityNext = true }}, + {"expired-project-session", http.StatusUnauthorized, func(f *keyAuditFixture) { f.projectStatus = http.StatusUnauthorized }}, + {"unavailable-projects", http.StatusBadGateway, func(f *keyAuditFixture) { f.projectStatus = http.StatusServiceUnavailable }}, + {"project-redirect", http.StatusBadGateway, func(f *keyAuditFixture) { f.projectRedirect = true }}, + {"missing-engine-viewer", http.StatusBadGateway, func(f *keyAuditFixture) { f.viewer = "" }}, + {"wrong-owner", http.StatusBadGateway, func(f *keyAuditFixture) { f.scopedKeyOverride = map[string]string{"accountId": "1a2"} }}, + {"wrong-key-id", http.StatusBadGateway, func(f *keyAuditFixture) { f.scopedKeyOverride = map[string]string{"id": "1a98"} }}, + {"wrong-key-type", http.StatusBadGateway, func(f *keyAuditFixture) { f.scopedKeyOverride = map[string]string{"type": "apiKeyRestricted"} }}, + {"wrong-engine-viewer", http.StatusBadGateway, func(f *keyAuditFixture) { f.scopedKeyOverride = map[string]string{"viewer": "1a2"} }}, + {"wrong-engine-context", http.StatusBadGateway, func(f *keyAuditFixture) { f.scopedKeyOverride = map[string]string{"context": "1p9"} }}, + {"missing-engine-context", http.StatusBadGateway, func(f *keyAuditFixture) { f.scopedKeyOverride = map[string]string{"context": ""} }}, + {"expired-scoped-session", http.StatusUnauthorized, func(f *keyAuditFixture) { f.scopedKeyStatus = http.StatusUnauthorized }}, + {"unavailable-scoped-key", http.StatusBadGateway, func(f *keyAuditFixture) { f.scopedKeyStatus = http.StatusServiceUnavailable }}, + {"scoped-key-redirect", http.StatusBadGateway, func(f *keyAuditFixture) { f.scopedKeyRedirect = true }}, + {"live-object-denied", http.StatusForbidden, func(f *keyAuditFixture) { f.scopedKeyStatus = http.StatusForbidden }}, + {"project-lost-after-key", http.StatusForbidden, func(f *keyAuditFixture) { f.loseProjectAfterKey = true }}, + {"viewer-changed-after-key", http.StatusBadGateway, func(f *keyAuditFixture) { f.changeViewerAfterKey = true }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.owner, fixture.requireProjectKey, fixture.accountScoped = "1p1", true, true + fixture.records = []map[string]any{keyAuditRecord("baseline", "1a99", "1p1", "2020-01-01T00:00:00Z")} + tc.setup(fixture) + request, _ := http.NewRequest(http.MethodGet, server.URL+keyAuditQueryPath+"?keyId=1a99&timeScope=all", nil) + request.Header.Set("Cookie", "R_SESS=authorized") + request.Header.Set("X-API-Project-ID", "1p1") // Caller hint cannot supply missing live authority. + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if response.StatusCode != tc.status || len(fixture.auditQueries) != 0 || fixture.redirectHits != 0 || strings.Contains(string(body), "baseline") { + t.Fatalf("unproven scope scanned events/followed redirect: status=%d scans=%d redirects=%d body=%s", response.StatusCode, len(fixture.auditQueries), fixture.redirectHits, body) + } + }) + } +} + +func TestKeyAuditProjectFallbackOnlyProbesVisibleScopesAndNeverGrantsForeignPersonalKey(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.owner, fixture.requireProjectKey = "1a2", true + fixture.projects = []map[string]any{{"type": "project", "id": "1p2"}, {"type": "project", "id": "1p1"}} + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99&timeScope=all") + defer response.Body.Close() + if response.StatusCode != http.StatusForbidden || len(fixture.auditQueries) != 0 { + t.Fatalf("membership borrowed foreign personal-key authority: %d", response.StatusCode) + } + var contexts []string + for _, query := range fixture.keyQueries { + contexts = append(contexts, query.Get("projectId")) + } + if strings.Join(contexts, ",") != ",1p1,1p2" { + t.Fatalf("scanned an unproven scope or skipped ordered live candidates: %#v", contexts) + } +} + +func TestKeyAuditProjectZeroBaselineIsReadRatherThanInventedAndDataLossFailsClosed(t *testing.T) { + for _, option := range []string{"empty", "denied", "redirect", "viewer-changed"} { + t.Run(option, func(t *testing.T) { + fixture, server := newKeyAuditFixture(t) + fixture.owner, fixture.requireProjectKey, fixture.accountScoped = "1p1", true, true + expected := http.StatusOK + switch option { + case "denied": + fixture.deniedProject, expected = "1p1", http.StatusForbidden + case "redirect": + fixture.auditRedirect, expected = true, http.StatusBadGateway + case "viewer-changed": + fixture.auditViewer, expected = "1a2", http.StatusBadGateway + } + response := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99&timeScope=all") + defer response.Body.Close() + var payload auditCollection + if json.NewDecoder(response.Body).Decode(&payload) != nil || response.StatusCode != expected || + len(fixture.auditQueries) == 0 || fixture.redirectHits != 0 { + t.Fatalf("data baseline not queried or loss was ignored: %d %#v", response.StatusCode, fixture.auditQueries) + } + if option == "empty" && (len(fixture.auditQueries) != 2 || payload.Pagination["total"] != float64(0)) { + t.Fatalf("zero was not proved across the live authorized contexts: %#v %#v", fixture.auditQueries, payload) + } + }) + } +} + +func TestKeyAuditStrictProjectRedirectDoesNotChangeGenericAuditRedirectBehavior(t *testing.T) { + redirectHits := 0 + upstream := &auditUpstreamFixture{} + upstream.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v2-beta/apikey/1a99", "/v2-beta/apikeyrestricted/1a99": + http.NotFound(writer, request) + case "/v2-beta/projects": + http.Redirect(writer, request, "/generic-project-list", http.StatusFound) + case "/generic-project-list": + redirectHits++ + writeFixtureCollection(writer, []map[string]any{{"type": "project", "id": "1p1"}}, "") + case "/v2-beta/accounts", "/v2-beta/auditlogs": + writeFixtureCollection(writer, nil, "") + default: + http.NotFound(writer, request) + } + })) + t.Cleanup(upstream.server.Close) + server := newAuditTestBroker(t, upstream) + generic := performAuditRequest(t, server, auditQueryPath+"?timeScope=all") + generic.Body.Close() + if generic.StatusCode != http.StatusOK || redirectHits != 1 { + t.Fatalf("generic audit redirect behavior changed: %d hits=%d", generic.StatusCode, redirectHits) + } + key := performAuditRequest(t, server, keyAuditQueryPath+"?keyId=1a99&timeScope=all") + key.Body.Close() + if key.StatusCode != http.StatusBadGateway || redirectHits != 1 { + t.Fatalf("key authority followed a redirect: %d hits=%d", key.StatusCode, redirectHits) + } +} diff --git a/server/security/openvex.json b/server/security/openvex.json index 20cdfe6b7e3..a23281f82de 100644 --- a/server/security/openvex.json +++ b/server/security/openvex.json @@ -1,6 +1,6 @@ { "@context": "https://openvex.dev/ns/v0.2.0", - "@id": "https://github.com/PastureStack/server/security/openvex/v1.6.518", + "@id": "https://github.com/PastureStack/server/security/openvex/v1.6.519", "author": "PastureStack Security", "timestamp": "2026-10-02T22:05:00Z", "version": 51, diff --git a/server/security/vendor-pending.json b/server/security/vendor-pending.json index 3c84bc40d35..0a5995e857c 100644 --- a/server/security/vendor-pending.json +++ b/server/security/vendor-pending.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "release": "v1.6.518", - "generatedAt": "2026-10-06T17:35:00Z", + "release": "v1.6.519", + "generatedAt": "2026-10-10T00:00:00Z", "policy": { "allowedSeverities": [ "LOW", @@ -13,16 +13,6 @@ "trivyTarget": "/rootfs (ubuntu 26.04)" }, "findings": [ - { - "vulnerabilityId": "CVE-2026-46675", - "severity": "MEDIUM", - "vendorStatus": "needs-evaluation", - "source": "https://ubuntu.com/security/CVE-2026-46675", - "reviewAfter": "2026-10-20", - "packages": [ - { "name": "libpng16-16t64", "installedVersion": "1.6.57-1" } - ] - }, { "vulnerabilityId": "CVE-2026-85091", "severity": "MEDIUM",