chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
*.java text eol=lf
|
||||||
|
*.gradle text eol=lf
|
||||||
|
*.properties text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
gradlew text eol=lf
|
||||||
|
gradlew.bat text eol=crlf
|
||||||
|
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.jar binary
|
||||||
|
*.zip binary
|
||||||
|
*.gz binary
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Replace this template owner when forking the repository. CODEOWNERS is effective only when the
|
||||||
|
# forge supports it and default-branch protection requires code-owner review.
|
||||||
|
|
||||||
|
/.trivyignore.yaml @DongHyeonka
|
||||||
|
/.github/ @DongHyeonka
|
||||||
|
/renovate.json @DongHyeonka
|
||||||
|
/flaky-quarantine.yaml @DongHyeonka
|
||||||
|
|
||||||
|
# Public security and compatibility baselines require an explicit maintainer review.
|
||||||
|
/docs/security/ @DongHyeonka
|
||||||
|
/src/config/architecture/modules.json @DongHyeonka
|
||||||
|
*.approved.json @DongHyeonka
|
||||||
|
*.approved.txt @DongHyeonka
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# Current repository CI controls. This file lists only mechanisms and jobs that exist in this
|
||||||
|
# checkout. Build/release supply-chain, image, signing, provenance, SBOM, and tag-release jobs are
|
||||||
|
# intentionally absent until their later bounded reconstruction.
|
||||||
|
#
|
||||||
|
# Fields:
|
||||||
|
# release_blocking: true, false, or conditional
|
||||||
|
# mechanism: gradle-custom-task, gradle-plugin-task, contract-test, workflow-job,
|
||||||
|
# or delegated-pending
|
||||||
|
# ref: task, plugin@task, repository-relative test path below src/, or workflow job id
|
||||||
|
# workflow/job: canonical workflow and job that execute or represent the control
|
||||||
|
# execution: check (through Gradle check), explicit (named in the job), or job
|
||||||
|
gates:
|
||||||
|
- id: format-lint
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-plugin-task
|
||||||
|
ref: com.diffplug.spotless@spotlessCheck
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: check
|
||||||
|
- id: unit-and-contract-tests
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-plugin-task
|
||||||
|
ref: java@test
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: check
|
||||||
|
- id: conditional-transport-qualification
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: conditionalTransportQualification
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: explicit
|
||||||
|
- id: clean-architecture-dependencies
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: verifyCleanArchitectureDependencies
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: check
|
||||||
|
- id: environment-contract
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: verifyEnvKeys
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: check
|
||||||
|
- id: one-type-per-file
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: verifyOneTypePerFile
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: check
|
||||||
|
- id: readme-command-drift
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: verifyReadmeCommands
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: check
|
||||||
|
- id: trivy-suppression-governance
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: verifyTrivyignore
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: check
|
||||||
|
- id: quarantine-sunset
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: verifyQuarantineSunset
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: check
|
||||||
|
- id: public-path-snapshot
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: verifyPublicPathSnapshot
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: explicit
|
||||||
|
- id: dependency-locks
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: verifyDependencyLocks
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: explicit
|
||||||
|
- id: architecture-contract-test
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: contract-test
|
||||||
|
ref: app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: check
|
||||||
|
- id: sample-off
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: sampleOffTest
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: sample-off
|
||||||
|
execution: explicit
|
||||||
|
- id: gate-matrix-lint
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: gate-matrix-lint
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: gate-matrix-lint
|
||||||
|
execution: job
|
||||||
|
- id: redis-sdk
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: redis-sdk
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: redis-sdk
|
||||||
|
execution: job
|
||||||
|
- id: jpa-candidate-evidence
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: jpa-candidate-evidence
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: jpa-candidate-evidence
|
||||||
|
execution: job
|
||||||
|
- id: jpa-r2-evidence
|
||||||
|
release_blocking: conditional
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: jpa-r2-evidence
|
||||||
|
workflow: jpa-r2-evidence.yml
|
||||||
|
job: jpa-r2-evidence
|
||||||
|
execution: job
|
||||||
|
- id: quality-release-gate
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: release-gate
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: release-gate
|
||||||
|
execution: job
|
||||||
|
- id: flaky-quarantine
|
||||||
|
release_blocking: false
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: quarantine
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quarantine
|
||||||
|
execution: job
|
||||||
|
- id: dependency-review
|
||||||
|
release_blocking: conditional
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: dependency-review
|
||||||
|
workflow: dependency-vulnerability.yml
|
||||||
|
job: dependency-review
|
||||||
|
execution: job
|
||||||
|
- id: dependency-submission
|
||||||
|
release_blocking: false
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: dependency-submission
|
||||||
|
workflow: dependency-vulnerability.yml
|
||||||
|
job: dependency-submission
|
||||||
|
execution: job
|
||||||
|
- id: filesystem-vulnerability-scan
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: trivy-fs
|
||||||
|
workflow: dependency-vulnerability.yml
|
||||||
|
job: trivy-fs
|
||||||
|
execution: job
|
||||||
|
- id: documentation-links
|
||||||
|
release_blocking: conditional
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: lychee
|
||||||
|
workflow: link-check.yml
|
||||||
|
job: lychee
|
||||||
|
execution: job
|
||||||
|
- id: object-storage-minio-managed-contract
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: objectStorageMinioContractTest
|
||||||
|
workflow: object-storage-qualification.yml
|
||||||
|
job: minio-managed-contract
|
||||||
|
execution: explicit
|
||||||
|
- id: poster-image-migration
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: posterImageMigrationTest
|
||||||
|
workflow: object-storage-qualification.yml
|
||||||
|
job: poster-image-v7-migration
|
||||||
|
execution: explicit
|
||||||
|
- id: object-storage-minio-managed-fault
|
||||||
|
release_blocking: conditional
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: objectStorageMinioFaultTest
|
||||||
|
workflow: object-storage-qualification.yml
|
||||||
|
job: minio-managed-fault
|
||||||
|
execution: explicit
|
||||||
|
- id: object-storage-aws-protected-qualification
|
||||||
|
release_blocking: conditional
|
||||||
|
mechanism: delegated-pending
|
||||||
|
ref: approval-gate-b
|
||||||
|
workflow: object-storage-qualification.yml
|
||||||
|
job: aws-managed-common-subset
|
||||||
|
execution: job
|
||||||
|
- id: redis-sdk-support-matrix
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: contract-test
|
||||||
|
ref: adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisSupportMatrixTest.java
|
||||||
|
workflow: ci-quality-gates.yml
|
||||||
|
job: quality-gates
|
||||||
|
execution: check
|
||||||
|
# Promoted from delegated-pending: the workflow is no longer manual-only. A pull request that
|
||||||
|
# touches the Redis leaf runs the standalone lane, and the full supported-version x topology
|
||||||
|
# matrix runs nightly and on a release candidate. While it was dispatch-only, a release could
|
||||||
|
# claim topology evidence that nobody had produced for that commit.
|
||||||
|
- id: redis-sdk-topology-evidence
|
||||||
|
release_blocking: conditional
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: topology-evidence
|
||||||
|
workflow: redis-sdk-topology.yml
|
||||||
|
job: topology-evidence
|
||||||
|
execution: job
|
||||||
|
- id: httpclient-stable-contract
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: httpClientStableContractTest
|
||||||
|
workflow: httpclient-release.yml
|
||||||
|
job: release-gate
|
||||||
|
execution: explicit
|
||||||
|
- id: httpclient-security-suite
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: httpClientSecurityTest
|
||||||
|
workflow: httpclient-release.yml
|
||||||
|
job: release-gate
|
||||||
|
execution: explicit
|
||||||
|
- id: httpclient-fault-injection
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: httpClientFailureInjectionTest
|
||||||
|
workflow: httpclient-release.yml
|
||||||
|
job: release-gate
|
||||||
|
execution: explicit
|
||||||
|
- id: httpclient-performance-certification
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: httpClientPerformanceTest
|
||||||
|
workflow: httpclient-release.yml
|
||||||
|
job: release-gate
|
||||||
|
execution: explicit
|
||||||
|
- id: httpclient-spring62-api-surface
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: spring62ApiSurfaceScan
|
||||||
|
workflow: httpclient-release.yml
|
||||||
|
job: release-gate
|
||||||
|
execution: explicit
|
||||||
|
# The 6.2 API-surface scan above proves the common packages compile against the older surface. It
|
||||||
|
# does not prove they run on it, and the two were being conflated: a lane called
|
||||||
|
# "spring62CompatibilityTest" reads as a runtime compatibility proof. The Gradle task is renamed to
|
||||||
|
# say what it does, and the runtime claim is registered here as its own delegated-pending control
|
||||||
|
# so the gap is a tracked absence rather than an unstated one. Executing it needs a Spring
|
||||||
|
# Framework 6.2 distribution resolved into a separate test runtime, which this repository's
|
||||||
|
# Boot 4.0 baseline does not carry.
|
||||||
|
- id: httpclient-spring62-runtime
|
||||||
|
release_blocking: conditional
|
||||||
|
mechanism: delegated-pending
|
||||||
|
ref: spring62-runtime-lane
|
||||||
|
workflow: httpclient-release.yml
|
||||||
|
job: release-gate
|
||||||
|
execution: job
|
||||||
|
- id: httpclient-spring70-compatibility
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: spring70CompatibilityTest
|
||||||
|
workflow: httpclient-release.yml
|
||||||
|
job: release-gate
|
||||||
|
execution: explicit
|
||||||
|
- id: httpclient-documentation-drift
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: workflow-job
|
||||||
|
ref: httpclient-documentation
|
||||||
|
workflow: httpclient-release.yml
|
||||||
|
job: httpclient-documentation
|
||||||
|
execution: job
|
||||||
|
- id: httpclient-event-loop-blocking
|
||||||
|
release_blocking: true
|
||||||
|
mechanism: gradle-custom-task
|
||||||
|
ref: httpClientBlockHoundTest
|
||||||
|
workflow: httpclient-release.yml
|
||||||
|
job: release-gate
|
||||||
|
execution: explicit
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# GitHub dependency-review configuration. The workflow guards this GitHub-only API so other forges
|
||||||
|
# use the platform-neutral Trivy filesystem snapshot instead.
|
||||||
|
fail-on-severity: high
|
||||||
|
|
||||||
|
fail-on-scopes:
|
||||||
|
- runtime
|
||||||
|
|
||||||
|
# Template legal posture: deny strong and network copyleft for newly introduced dependencies.
|
||||||
|
# Forks must have their legal/security owner review this organization-specific list.
|
||||||
|
deny-licenses:
|
||||||
|
- GPL-2.0-only
|
||||||
|
- GPL-2.0-or-later
|
||||||
|
- GPL-3.0-only
|
||||||
|
- GPL-3.0-or-later
|
||||||
|
- AGPL-3.0-only
|
||||||
|
- AGPL-3.0-or-later
|
||||||
|
|
||||||
|
comment-summary-in-pr: never
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Dependency Vulnerability Policy
|
||||||
|
|
||||||
|
This policy is enforced by
|
||||||
|
[`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml),
|
||||||
|
[`dependency-review-config.yml`](dependency-review-config.yml),
|
||||||
|
[`../.trivyignore.yaml`](../.trivyignore.yaml), `verifyTrivyignore`, CODEOWNERS, and
|
||||||
|
[`../renovate.json`](../renovate.json).
|
||||||
|
|
||||||
|
## Execution and platform boundary
|
||||||
|
|
||||||
|
Canonical workflow files live under `.github/workflows`. The current origin is Gitea and
|
||||||
|
server-side Actions is externally disabled; committing these controls does not enable or prove
|
||||||
|
remote execution. An administrator must enable Actions, provide compatible runners, configure
|
||||||
|
required checks, and require code-owner review separately.
|
||||||
|
|
||||||
|
External actions execute only by verified full commit SHA; inline comments retain the immutable
|
||||||
|
release tag for review. GitHub dependency review publishes check output but never writes a PR
|
||||||
|
summary comment, preserving the workflow-wide `contents: read` permission.
|
||||||
|
|
||||||
|
GitHub dependency review and dependency submission depend on GitHub.com APIs. They are guarded by
|
||||||
|
`github.server_url == 'https://github.com'` and intentionally skip on Gitea. `trivy-fs` is the
|
||||||
|
platform-neutral release-blocking snapshot and runs for pull requests, `main` pushes, daily
|
||||||
|
schedule, and manual dispatch.
|
||||||
|
|
||||||
|
The later supply-chain slice owns image builds/scans, SBOM, signing, provenance, tag release, and
|
||||||
|
retention. This policy does not claim those absent jobs.
|
||||||
|
|
||||||
|
## Severity and response
|
||||||
|
|
||||||
|
| Severity | CI posture | Target remediation |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| KEV / Critical | block | 7 days or the CISA due date, whichever is sooner |
|
||||||
|
| High | block | 30 days |
|
||||||
|
| Medium | advisory | 90 days |
|
||||||
|
| Low | advisory | best effort |
|
||||||
|
|
||||||
|
High/Critical is the template's release threshold. The exact threshold and remediation targets are
|
||||||
|
team policy rather than an external mandate and should be reviewed when the template is adopted.
|
||||||
|
GitHub dependency review applies the same High threshold to newly introduced runtime dependencies.
|
||||||
|
|
||||||
|
The Trivy JSON snapshot is also compared with the CISA Known Exploited Vulnerabilities catalog.
|
||||||
|
Any intersection blocks regardless of Trivy severity. Before intersection, CI requires nonblank
|
||||||
|
catalog metadata, a positive integral declared count, a non-empty vulnerability array whose length
|
||||||
|
matches that count, and unique CVE-pattern `cveID` values. A missing, empty, malformed,
|
||||||
|
count-inconsistent, duplicate, or unreachable KEV feed fails closed; configure the `KEV_FEED_URL`
|
||||||
|
repository variable to an approved internal mirror when direct CISA access is unavailable.
|
||||||
|
|
||||||
|
Candidate extraction also validates Trivy's JSON first: the top level must be an object with a
|
||||||
|
non-empty `Results` array; each result is an object whose `Vulnerabilities` value is null or an
|
||||||
|
array; and every listed vulnerability has a nonblank string `VulnerabilityID`. Invalid scanner
|
||||||
|
output fails closed instead of becoming an empty candidate set.
|
||||||
|
|
||||||
|
## Scanner and network requirements
|
||||||
|
|
||||||
|
Trivy scans the committed filesystem and Gradle lockfiles. High/Critical findings exit non-zero;
|
||||||
|
Medium/Low findings are reported with exit zero. Scanner database or tool-download failures remain
|
||||||
|
failures rather than silently producing an empty result.
|
||||||
|
|
||||||
|
Runners need HTTPS egress to:
|
||||||
|
|
||||||
|
- the configured Trivy binary source (GitHub Releases by default);
|
||||||
|
- Trivy vulnerability databases (the scanner defaults, commonly OCI registries);
|
||||||
|
- jq releases, unless `JQ_DOWNLOAD_BASE_URL` points to an internal mirror;
|
||||||
|
- the CISA KEV feed, unless `KEV_FEED_URL` points to an internal mirror;
|
||||||
|
- GitHub action sources when the forge does not mirror actions.
|
||||||
|
|
||||||
|
Closed networks must mirror the pinned Trivy/jq artifacts and checksums, set
|
||||||
|
`TRIVY_DOWNLOAD_BASE_URL` and `JQ_DOWNLOAD_BASE_URL`, configure Trivy's documented database mirror
|
||||||
|
environment, and set `KEV_FEED_URL`. Mirror availability and freshness are operational
|
||||||
|
dependencies; stale mirrors can delay detection.
|
||||||
|
|
||||||
|
## Suppression governance
|
||||||
|
|
||||||
|
The only suppression source is repository-root `.trivyignore.yaml`. Every Trivy scan passes it
|
||||||
|
explicitly with `--ignorefile .trivyignore.yaml`. Each future entry must contain an identifier, a
|
||||||
|
non-empty rationale, and a future expiry no more than 90 days away. `verifyTrivyignore` validates
|
||||||
|
the shape and expiry; CODEOWNERS plus branch protection controls who may approve the change.
|
||||||
|
Neither control substitutes for the other.
|
||||||
|
|
||||||
|
Do not use an ad-hoc ignore file or an inline scanner bypass. An expired suppression is removed or
|
||||||
|
renewed with fresh owner review and current evidence.
|
||||||
|
|
||||||
|
## Dependency update policy
|
||||||
|
|
||||||
|
Renovate is the checked-in security-update bot configuration. Patch/pin/digest security updates
|
||||||
|
may auto-merge only after every configured required check succeeds; minor and major updates require
|
||||||
|
human review. Dependency declarations and all 19 `gradle.lockfile` files must move together, and
|
||||||
|
`verifyDependencyLocks` remains release-blocking.
|
||||||
|
|
||||||
|
Bot alerts are not a transitive-dependency backstop on every forge. Use a Gradle constraint or
|
||||||
|
resolution rule for a vulnerable transitive dependency, refresh locks deliberately, and retain the
|
||||||
|
full-snapshot Trivy scan.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
## What changed and why
|
||||||
|
|
||||||
|
<!-- Summarize the behavior, architecture boundary, or policy being changed. -->
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] I ran the focused test for each changed leaf.
|
||||||
|
- [ ] I ran `cd src && ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks`.
|
||||||
|
- [ ] I did not add an unregistered production module dependency.
|
||||||
|
- [ ] Dependency changes include refreshed `gradle.lockfile` files and a strict-lock verification.
|
||||||
|
- [ ] Trivy suppressions include an owner-reviewed reason and an expiry within 90 days.
|
||||||
|
- [ ] Any quarantined test is registered in `flaky-quarantine.yaml` with a reason, issue, and
|
||||||
|
`quarantined_since` date.
|
||||||
|
- [ ] I documented checks that could not run and their remaining risk.
|
||||||
|
|
||||||
|
## Compatibility and operations
|
||||||
|
|
||||||
|
<!-- Note public API/config changes, migration needs, rollout/rollback impact, and external blockers. -->
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly JQ_VERSION='1.8.1'
|
||||||
|
readonly JQ_SHA256_AMD64='020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d'
|
||||||
|
readonly JQ_SHA256_ARM64='6bc62f25981328edd3cfcfe6fe51b073f2d7e7710d7ef7fcdac28d4e384fc3d4'
|
||||||
|
readonly DOWNLOAD_BASE_URL="${JQ_DOWNLOAD_BASE_URL:-https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}}"
|
||||||
|
|
||||||
|
: "${RUNNER_TEMP:?RUNNER_TEMP must be set by the CI runner}"
|
||||||
|
: "${GITHUB_PATH:?GITHUB_PATH must be set by the CI runner}"
|
||||||
|
|
||||||
|
architecture="${RUNNER_ARCH:-$(uname -m)}"
|
||||||
|
case "${architecture}" in
|
||||||
|
X64 | x86_64 | amd64)
|
||||||
|
asset='jq-linux-amd64'
|
||||||
|
expected_sha256="${JQ_SHA256_AMD64}"
|
||||||
|
;;
|
||||||
|
ARM64 | aarch64 | arm64)
|
||||||
|
asset='jq-linux-arm64'
|
||||||
|
expected_sha256="${JQ_SHA256_ARM64}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '::error::install-jq: unsupported runner architecture: %s\n' "${architecture}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
install_dir="${RUNNER_TEMP}/jq-${JQ_VERSION}/bin"
|
||||||
|
destination="${install_dir}/jq"
|
||||||
|
mkdir -p "${install_dir}"
|
||||||
|
|
||||||
|
temporary="$(mktemp "${RUNNER_TEMP}/jq-${JQ_VERSION}.XXXXXX")"
|
||||||
|
trap 'rm -f "${temporary}"' EXIT
|
||||||
|
|
||||||
|
curl --fail --show-error --silent --location --retry 3 \
|
||||||
|
--proto '=https' --tlsv1.2 \
|
||||||
|
"${DOWNLOAD_BASE_URL}/${asset}" \
|
||||||
|
--output "${temporary}"
|
||||||
|
printf '%s %s\n' "${expected_sha256}" "${temporary}" | sha256sum -c -
|
||||||
|
chmod 0755 "${temporary}"
|
||||||
|
mv "${temporary}" "${destination}"
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}"
|
||||||
|
installed_version="$("${destination}" --version)"
|
||||||
|
if [[ "${installed_version}" != "jq-${JQ_VERSION}" ]]; then
|
||||||
|
printf '::error::install-jq: expected jq-%s, got %s\n' "${JQ_VERSION}" "${installed_version}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'install-jq: %s installed under RUNNER_TEMP\n' "${installed_version}"
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
if (( $# > 1 )); then
|
||||||
|
printf '::error::gate-matrix-lint: expected zero arguments or one repository root\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( $# == 1 )); then
|
||||||
|
if [[ ! -d "$1" ]]; then
|
||||||
|
printf '::error::gate-matrix-lint: repository root is not a directory: %s\n' "$1" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
REPO_ROOT="$(cd -- "$1" && pwd -P)"
|
||||||
|
else
|
||||||
|
REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
|
||||||
|
EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
|
||||||
|
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
|
||||||
|
printf '::error::gate-matrix-lint: script location must be repository .github/scripts directory\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
readonly REPO_ROOT
|
||||||
|
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
|
||||||
|
# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to
|
||||||
|
# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening,
|
||||||
|
# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime*
|
||||||
|
# claim, distinct from the API-surface scan that was standing in for it.
|
||||||
|
readonly EXPECTED_GATE_COUNT=38
|
||||||
|
|
||||||
|
if [[ ! -f "${MATRIX}" ]]; then
|
||||||
|
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
records="$(
|
||||||
|
awk '
|
||||||
|
function flush() {
|
||||||
|
if (id != "") {
|
||||||
|
printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", id, blocking, mechanism, ref, workflow, job, execution
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/ {
|
||||||
|
flush()
|
||||||
|
id=$0
|
||||||
|
sub(/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/, "", id)
|
||||||
|
blocking=mechanism=ref=workflow=job=execution=""
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]+release_blocking:[[:space:]]*/ {
|
||||||
|
blocking=$0
|
||||||
|
sub(/^[[:space:]]+release_blocking:[[:space:]]*/, "", blocking)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]+mechanism:[[:space:]]*/ {
|
||||||
|
mechanism=$0
|
||||||
|
sub(/^[[:space:]]+mechanism:[[:space:]]*/, "", mechanism)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]+ref:[[:space:]]*/ {
|
||||||
|
ref=$0
|
||||||
|
sub(/^[[:space:]]+ref:[[:space:]]*/, "", ref)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]+workflow:[[:space:]]*/ {
|
||||||
|
workflow=$0
|
||||||
|
sub(/^[[:space:]]+workflow:[[:space:]]*/, "", workflow)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]+job:[[:space:]]*/ {
|
||||||
|
job=$0
|
||||||
|
sub(/^[[:space:]]+job:[[:space:]]*/, "", job)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]+execution:[[:space:]]*/ {
|
||||||
|
execution=$0
|
||||||
|
sub(/^[[:space:]]+execution:[[:space:]]*/, "", execution)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
END { flush() }
|
||||||
|
' "${MATRIX}"
|
||||||
|
)"
|
||||||
|
|
||||||
|
declare -A seen_ids=()
|
||||||
|
declare -a failures=()
|
||||||
|
total=0
|
||||||
|
verified=0
|
||||||
|
delegated=0
|
||||||
|
|
||||||
|
job_body() {
|
||||||
|
local workflow_file="$1"
|
||||||
|
local job_id="$2"
|
||||||
|
awk -v target="${job_id}" '
|
||||||
|
$0 ~ "^ " target ":[[:space:]]*$" { inside=1; print; next }
|
||||||
|
inside && $0 ~ "^ [A-Za-z0-9_-]+:[[:space:]]*$" { exit }
|
||||||
|
inside { print }
|
||||||
|
' "${workflow_file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
gradle_command_has_safe_literal_grammar() {
|
||||||
|
local command="$1"
|
||||||
|
[[ "${command}" =~ ^\./gradlew([[:space:]]+[A-Za-z0-9_.:/@=,+-]+)+[[:space:]]*$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
gradle_token_suppresses_execution() {
|
||||||
|
local token="$1"
|
||||||
|
case "${token}" in
|
||||||
|
'--dry-run'|'--dry-run='*|'-m'|'-x'|'-x'*|'--exclude-task'|'--exclude-task='*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
gradle_token_is_allowed_gate_argument() {
|
||||||
|
local token="$1"
|
||||||
|
case "${token}" in
|
||||||
|
'--no-daemon'|'--stacktrace'|'--warning-mode=fail') return 0 ;;
|
||||||
|
esac
|
||||||
|
[[ "${token}" =~ ^:?[A-Za-z0-9_][A-Za-z0-9_.-]*(:[A-Za-z0-9_][A-Za-z0-9_.-]*)*$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
gradle_plugin_is_applied() {
|
||||||
|
local plugin_id="$1"
|
||||||
|
grep -RqsF --include='build.gradle' -- "id '${plugin_id}'" "${REPO_ROOT}/src" \
|
||||||
|
|| grep -RqsF --include='build.gradle' -- "id \"${plugin_id}\"" "${REPO_ROOT}/src" \
|
||||||
|
|| grep -RqsF --include='build.gradle' -- "apply plugin: '${plugin_id}'" "${REPO_ROOT}/src" \
|
||||||
|
|| grep -RqsF --include='build.gradle' -- "apply plugin: \"${plugin_id}\"" "${REPO_ROOT}/src"
|
||||||
|
}
|
||||||
|
|
||||||
|
gradle_custom_task_is_registered_in_build_file() {
|
||||||
|
local task_name="$1"
|
||||||
|
local build_file="$2"
|
||||||
|
if grep -qsE -- "tasks\\.register\\(['\"]${task_name}['\"]" "${build_file}"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
awk -v required_task="${task_name}" '
|
||||||
|
index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 }
|
||||||
|
inside_registration && /^[[:space:]]*name:[[:space:]]*/ {
|
||||||
|
candidate=$0
|
||||||
|
sub(/^[[:space:]]*name:[[:space:]]*/, "", candidate)
|
||||||
|
quote=substr(candidate, 1, 1)
|
||||||
|
if (quote != "\"" && quote != sprintf("%c", 39)) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
candidate=substr(candidate, 2)
|
||||||
|
closing_quote=index(candidate, quote)
|
||||||
|
if (closing_quote == 0) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
candidate=substr(candidate, 1, closing_quote - 1)
|
||||||
|
if (candidate == required_task) {
|
||||||
|
found=1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inside_registration && /\)[[:space:]]*$/ { inside_registration=0 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' "${build_file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
gradle_custom_task_is_registered() {
|
||||||
|
local task_name="$1"
|
||||||
|
local build_file
|
||||||
|
while IFS= read -r -d '' build_file; do
|
||||||
|
if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < <(find "${REPO_ROOT}/src" -type f -name '*.gradle' -print0)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
gradle_token_matches_registered_task() {
|
||||||
|
local token="$1"
|
||||||
|
local required_task="$2"
|
||||||
|
local project_path build_file
|
||||||
|
if [[ "${token}" == "${required_task}" || "${token}" == ":${required_task}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ "${token}" != :* || "${token}" != *:"${required_task}" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
project_path="${token%:"${required_task}"}"
|
||||||
|
project_path="${project_path#:}"
|
||||||
|
project_path="${project_path%:}"
|
||||||
|
build_file="${REPO_ROOT}/src/${project_path//:/\/}/build.gradle"
|
||||||
|
[[ -f "${build_file}" ]] \
|
||||||
|
&& gradle_custom_task_is_registered_in_build_file "${required_task}" "${build_file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
job_runs_gradle_task() {
|
||||||
|
local workflow_file="$1"
|
||||||
|
local job_id="$2"
|
||||||
|
local required_task="$3"
|
||||||
|
local command token
|
||||||
|
local found_task suppressed
|
||||||
|
local -a tokens=()
|
||||||
|
|
||||||
|
while IFS= read -r command; do
|
||||||
|
if ! gradle_command_has_safe_literal_grammar "${command}"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
read -r -a tokens <<< "${command}"
|
||||||
|
if (( ${#tokens[@]} < 2 )) || [[ "${tokens[0]}" != './gradlew' ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
found_task=0
|
||||||
|
suppressed=0
|
||||||
|
for token in "${tokens[@]:1}"; do
|
||||||
|
case "${token}" in
|
||||||
|
'&&'|'||'|';'|'|'|'#'*) break ;;
|
||||||
|
esac
|
||||||
|
if gradle_token_suppresses_execution "${token}"; then
|
||||||
|
suppressed=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! gradle_token_is_allowed_gate_argument "${token}"; then
|
||||||
|
suppressed=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if gradle_token_matches_registered_task "${token}" "${required_task}"; then
|
||||||
|
found_task=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if (( found_task == 1 && suppressed == 0 )); then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < <(
|
||||||
|
job_body "${workflow_file}" "${job_id}" | awk '
|
||||||
|
/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/ {
|
||||||
|
command=$0
|
||||||
|
sub(/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/, "", command)
|
||||||
|
if (command !~ /^(\||>)/) {
|
||||||
|
print command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
|
||||||
|
[[ -z "${id}" ]] && continue
|
||||||
|
total=$((total + 1))
|
||||||
|
|
||||||
|
if [[ -n "${seen_ids[${id}]:-}" ]]; then
|
||||||
|
failures+=("duplicate gate id '${id}'")
|
||||||
|
fi
|
||||||
|
seen_ids["${id}"]=1
|
||||||
|
|
||||||
|
if [[ -z "${blocking}" || -z "${mechanism}" || -z "${ref}" || -z "${workflow}" \
|
||||||
|
|| -z "${job}" || -z "${execution}" ]]; then
|
||||||
|
failures+=("gate '${id}' has an empty required field")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ ! "${blocking}" =~ ^(true|false|conditional)$ ]]; then
|
||||||
|
failures+=("gate '${id}' has invalid release_blocking '${blocking}'")
|
||||||
|
fi
|
||||||
|
if [[ ! "${workflow}" =~ ^[A-Za-z0-9._-]+\.ya?ml$ || ! "${job}" =~ ^[A-Za-z0-9_-]+$ ]]; then
|
||||||
|
failures+=("gate '${id}' has an unsafe workflow or job identifier")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
workflow_file="${REPO_ROOT}/.github/workflows/${workflow}"
|
||||||
|
if [[ ! -f "${workflow_file}" ]]; then
|
||||||
|
failures+=("gate '${id}' references missing workflow '.github/workflows/${workflow}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! grep -Eqs -- "^[[:space:]]{2}${job}:[[:space:]]*$" "${workflow_file}"; then
|
||||||
|
failures+=("gate '${id}' references missing job '${job}' in '${workflow}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${mechanism}" in
|
||||||
|
gradle-custom-task)
|
||||||
|
if [[ ! "${ref}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
|
||||||
|
failures+=("gate '${id}' has unsafe Gradle custom task ref '${ref}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! gradle_custom_task_is_registered "${ref}"; then
|
||||||
|
failures+=("gate '${id}' references unregistered Gradle task '${ref}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
gradle-plugin-task)
|
||||||
|
plugin="${ref%@*}"
|
||||||
|
task="${ref#*@}"
|
||||||
|
if [[ "${plugin}" == "${ref}" \
|
||||||
|
|| ! "${plugin}" =~ ^[A-Za-z][A-Za-z0-9.-]*$ \
|
||||||
|
|| ! "${task}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
|
||||||
|
failures+=("gate '${id}' has unsafe Gradle plugin task ref '${ref}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! gradle_plugin_is_applied "${plugin}"; then
|
||||||
|
failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
contract-test)
|
||||||
|
if [[ "${ref}" == /* || "${ref}" == *".."* || ! -f "${REPO_ROOT}/src/${ref}" ]]; then
|
||||||
|
failures+=("gate '${id}' references missing or unsafe contract test 'src/${ref}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
workflow-job)
|
||||||
|
if [[ "${ref}" != "${job}" ]]; then
|
||||||
|
failures+=("gate '${id}' workflow-job ref '${ref}' must equal job '${job}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
delegated-pending)
|
||||||
|
delegated=$((delegated + 1))
|
||||||
|
printf "gate '%s': explicitly delegated-pending\n" "${id}"
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
failures+=("gate '${id}' has unknown mechanism '${mechanism}'")
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "${execution}" in
|
||||||
|
check)
|
||||||
|
if ! job_runs_gradle_task "${workflow_file}" "${job}" 'check'; then
|
||||||
|
failures+=("gate '${id}' expects Gradle check in job '${job}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "${mechanism}" == "gradle-custom-task" ]] \
|
||||||
|
&& ! grep -RqsE -- "dependsOn.*named\\(['\"]${ref}['\"]\\)" "${REPO_ROOT}/src" \
|
||||||
|
--include='build.gradle'; then
|
||||||
|
failures+=("gate '${id}' task '${ref}' exists but is not wired into Gradle check")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
explicit)
|
||||||
|
if ! job_runs_gradle_task "${workflow_file}" "${job}" "${ref}"; then
|
||||||
|
failures+=("gate '${id}' task '${ref}' is not explicit in job '${job}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
job)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
failures+=("gate '${id}' has unknown execution '${execution}'")
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
verified=$((verified + 1))
|
||||||
|
done <<< "${records}"
|
||||||
|
|
||||||
|
if (( total != EXPECTED_GATE_COUNT )); then
|
||||||
|
failures+=("matrix has ${total} gates; expected ${EXPECTED_GATE_COUNT}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'gate-matrix-lint: %d gates, %d verified, %d delegated-pending\n' \
|
||||||
|
"${total}" "${verified}" "${delegated}"
|
||||||
|
if (( ${#failures[@]} > 0 )); then
|
||||||
|
printf '::error::gate-matrix-lint: %d drift(s) found\n' "${#failures[@]}" >&2
|
||||||
|
for failure in "${failures[@]}"; do
|
||||||
|
printf ' - %s\n' "${failure}" >&2
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'gate-matrix-lint: OK\n'
|
||||||
Executable
+740
@@ -0,0 +1,740 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly EXPECTED_DISTRIBUTION_SUFFIX='/gradle-9.0.0-bin.zip'
|
||||||
|
readonly EXPECTED_DISTRIBUTION_SHA256='8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b'
|
||||||
|
readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3'
|
||||||
|
readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6'
|
||||||
|
readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1'
|
||||||
|
readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}"
|
||||||
|
# Workflow-lock update procedure (only after intentional review of the complete workflow diff):
|
||||||
|
# find .github/workflows -mindepth 1 -maxdepth 1 \
|
||||||
|
# \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing
|
||||||
|
# find .github/workflows -mindepth 1 -maxdepth 1 -type f \
|
||||||
|
# \( -name '*.yml' -o -name '*.yaml' \) -print0 \
|
||||||
|
# | LC_ALL=C sort -z | xargs -0 sha256sum
|
||||||
|
# Replace this entire sorted array in the same reviewed change. Never refresh a single digest
|
||||||
|
# merely to make this verifier pass.
|
||||||
|
readonly EXPECTED_WORKFLOW_LOCK=(
|
||||||
|
'a5986c6d865e28d6160dc09c513c430c9d9c38d154c67423cb34448cb1e9863c .github/workflows/ci-quality-gates.yml'
|
||||||
|
'59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml'
|
||||||
|
'1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml'
|
||||||
|
'26812e16b8d6e4472543ddd49c7b16ee6b7697834ddbb653fa0424befd71c544 .github/workflows/fileserver-pr.yml'
|
||||||
|
'86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml'
|
||||||
|
'58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml'
|
||||||
|
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
|
||||||
|
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
|
||||||
|
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
|
||||||
|
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
|
||||||
|
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
|
||||||
|
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
|
||||||
|
)
|
||||||
|
readonly EXPECTED_WRAPPER_PROPERTIES=(
|
||||||
|
'distributionBase=GRADLE_USER_HOME'
|
||||||
|
'distributionPath=wrapper/dists'
|
||||||
|
"distributionUrl=https\://services.gradle.org/distributions${EXPECTED_DISTRIBUTION_SUFFIX}"
|
||||||
|
"distributionSha256Sum=${EXPECTED_DISTRIBUTION_SHA256}"
|
||||||
|
'networkTimeout=10000'
|
||||||
|
'validateDistributionUrl=true'
|
||||||
|
'zipStoreBase=GRADLE_USER_HOME'
|
||||||
|
'zipStorePath=wrapper/dists'
|
||||||
|
)
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'gradle-wrapper-contract: FAIL: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
fail 'expected exactly one repository-root argument'
|
||||||
|
fi
|
||||||
|
|
||||||
|
readonly REPOSITORY_ROOT=$1
|
||||||
|
[[ -d "${REPOSITORY_ROOT}" ]] || fail "repository root is not a directory: ${REPOSITORY_ROOT}"
|
||||||
|
|
||||||
|
readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties"
|
||||||
|
readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar"
|
||||||
|
readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows"
|
||||||
|
|
||||||
|
[[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}"
|
||||||
|
[[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}"
|
||||||
|
[[ -d "${WORKFLOWS_DIRECTORY}" ]] || fail "missing workflows directory: ${WORKFLOWS_DIRECTORY}"
|
||||||
|
|
||||||
|
if ! printf '%s\n' "${EXPECTED_WRAPPER_PROPERTIES[@]}" | cmp -s - "${WRAPPER_PROPERTIES}"; then
|
||||||
|
fail 'wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract'
|
||||||
|
fi
|
||||||
|
|
||||||
|
readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1}')
|
||||||
|
[[ "${actual_wrapper_jar_sha256}" == "${EXPECTED_WRAPPER_JAR_SHA256}" ]] \
|
||||||
|
|| fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}"
|
||||||
|
|
||||||
|
workflow_lock_valid=1
|
||||||
|
actual_workflow_lock=()
|
||||||
|
while IFS= read -r -d '' locked_workflow; do
|
||||||
|
locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/}
|
||||||
|
if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then
|
||||||
|
locked_workflow_sha256='<invalid-file-type>'
|
||||||
|
else
|
||||||
|
locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $1}')
|
||||||
|
fi
|
||||||
|
actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}")
|
||||||
|
done < <(
|
||||||
|
find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \
|
||||||
|
\( -name '*.yml' -o -name '*.yaml' \) -print0 \
|
||||||
|
| LC_ALL=C sort -z
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]}
|
||||||
|
if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then
|
||||||
|
workflow_lock_entry_count=${#actual_workflow_lock[@]}
|
||||||
|
fi
|
||||||
|
for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do
|
||||||
|
expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-<missing>}
|
||||||
|
actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-<missing>}
|
||||||
|
if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then
|
||||||
|
printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \
|
||||||
|
"${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2
|
||||||
|
workflow_lock_valid=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
workflow_count=0
|
||||||
|
gradle_job_count=0
|
||||||
|
while IFS= read -r -d '' workflow; do
|
||||||
|
if ! awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" '
|
||||||
|
function reset_step(known_field) {
|
||||||
|
step_active = 0
|
||||||
|
run_block = 0
|
||||||
|
for (known_field in step_fields) {
|
||||||
|
delete step_fields[known_field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset_job() {
|
||||||
|
job = ""
|
||||||
|
in_steps = 0
|
||||||
|
steps_count = 0
|
||||||
|
reset_step()
|
||||||
|
}
|
||||||
|
|
||||||
|
function indentation(line, first_non_space) {
|
||||||
|
if (line ~ /^ *$/) {
|
||||||
|
return length(line)
|
||||||
|
}
|
||||||
|
first_non_space = match(line, /[^ ]/)
|
||||||
|
return first_non_space - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function trim(value) {
|
||||||
|
sub(/^[[:space:]]+/, "", value)
|
||||||
|
sub(/[[:space:]]+$/, "", value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function grammar_error(message) {
|
||||||
|
printf "%s: job %s %s\n", workflow, job == "" ? "<unknown>" : job, message > "/dev/stderr"
|
||||||
|
invalid = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflow_grammar_error(message) {
|
||||||
|
printf "%s: %s\n", workflow, message > "/dev/stderr"
|
||||||
|
invalid = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate_job_shape() {
|
||||||
|
if (job != "" && steps_count != 1) {
|
||||||
|
grammar_error("must contain exactly one canonical steps block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function is_allowed_step_field(field) {
|
||||||
|
return field == "name" \
|
||||||
|
|| field == "id" \
|
||||||
|
|| field == "uses" \
|
||||||
|
|| field == "run" \
|
||||||
|
|| field == "if" \
|
||||||
|
|| field == "shell" \
|
||||||
|
|| field == "with" \
|
||||||
|
|| field == "env" \
|
||||||
|
|| field == "working-directory" \
|
||||||
|
|| field == "continue-on-error" \
|
||||||
|
|| field == "timeout-minutes"
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate_uses_scalar(value, first, quote, closing, index_value, suffix, action, single_quote) {
|
||||||
|
value = trim(value)
|
||||||
|
if (value == "" || index(value, "\\") != 0) {
|
||||||
|
grammar_error("has unsupported uses scalar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
first = substr(value, 1, 1)
|
||||||
|
single_quote = sprintf("%c", 39)
|
||||||
|
if (first == "\"" || first == single_quote) {
|
||||||
|
quote = first
|
||||||
|
closing = 0
|
||||||
|
for (index_value = 2; index_value <= length(value); index_value++) {
|
||||||
|
if (substr(value, index_value, 1) == quote) {
|
||||||
|
closing = index_value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closing == 0) {
|
||||||
|
grammar_error("has unsupported uses scalar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suffix = substr(value, closing + 1)
|
||||||
|
if (suffix !~ /^[[:space:]]*(#.*)?$/) {
|
||||||
|
grammar_error("has unsupported uses scalar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action = substr(value, 2, closing - 2)
|
||||||
|
if (index(action, quote) != 0) {
|
||||||
|
grammar_error("has unsupported uses scalar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
action = value
|
||||||
|
sub(/[[:space:]]+#.*$/, "", action)
|
||||||
|
action = trim(action)
|
||||||
|
if (action ~ /["'"'"'\\]/ || action ~ /^[*!&|>]/) {
|
||||||
|
grammar_error("has unsupported uses scalar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action !~ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*@[A-Za-z0-9_.\/-]+$/ \
|
||||||
|
&& action !~ /^\.\/[A-Za-z0-9_.\/-]+$/ \
|
||||||
|
&& action !~ /^docker:\/\/[^[:space:]]+$/) {
|
||||||
|
grammar_error("has unsupported uses scalar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate_run_scalar(value, first) {
|
||||||
|
value = trim(value)
|
||||||
|
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
|
||||||
|
run_block = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
first = substr(value, 1, 1)
|
||||||
|
if (value == "" || first == "\"" || first == sprintf("%c", 39) \
|
||||||
|
|| first ~ /[*&!|>]/ || index(value, "\\") != 0) {
|
||||||
|
grammar_error("has unsupported run scalar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate_step_field(content, field, value, separator) {
|
||||||
|
content = trim(content)
|
||||||
|
if (content ~ /^[{[]/) {
|
||||||
|
grammar_error("contains unsupported flow-style step syntax")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (content ~ /^<</) {
|
||||||
|
grammar_error("contains a forbidden step merge key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (content ~ /^[*&!]/) {
|
||||||
|
grammar_error("contains unsupported step anchor, alias, or tag syntax")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (content !~ /^[A-Za-z][A-Za-z0-9-]*:/) {
|
||||||
|
grammar_error("contains unsupported step field syntax")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
separator = index(content, ":")
|
||||||
|
field = substr(content, 1, separator - 1)
|
||||||
|
value = substr(content, separator + 1)
|
||||||
|
sub(/^[[:space:]]*/, "", value)
|
||||||
|
if (!is_allowed_step_field(field)) {
|
||||||
|
grammar_error("contains unsupported step field: " field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (field in step_fields) {
|
||||||
|
grammar_error("contains duplicate step field: " field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step_fields[field] = 1
|
||||||
|
|
||||||
|
if (field == "uses") {
|
||||||
|
validate_uses_scalar(value)
|
||||||
|
} else if (field == "run") {
|
||||||
|
validate_run_scalar(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BEGIN {
|
||||||
|
in_jobs = 0
|
||||||
|
invalid = 0
|
||||||
|
jobs_count = 0
|
||||||
|
single_quote = sprintf("%c", 39)
|
||||||
|
reset_job()
|
||||||
|
}
|
||||||
|
|
||||||
|
/^jobs:/ {
|
||||||
|
if ($0 !~ /^jobs:[[:space:]]*(#.*)?$/) {
|
||||||
|
workflow_grammar_error("jobs container must use a canonical block mapping")
|
||||||
|
next
|
||||||
|
}
|
||||||
|
jobs_count++
|
||||||
|
if (jobs_count != 1) {
|
||||||
|
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
|
||||||
|
}
|
||||||
|
in_jobs = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
/^"jobs":/ {
|
||||||
|
workflow_grammar_error("jobs container must use a canonical block mapping")
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
substr($0, 1, 7) == single_quote "jobs" single_quote ":" {
|
||||||
|
workflow_grammar_error("jobs container must use a canonical block mapping")
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
run_block == 0 && /^<<:/ {
|
||||||
|
workflow_grammar_error("workflow contains a forbidden merge key")
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
in_jobs && /^[^[:space:]#]/ {
|
||||||
|
validate_job_shape()
|
||||||
|
reset_job()
|
||||||
|
in_jobs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
in_jobs && /^ [^[:space:]#]/ {
|
||||||
|
if ($0 !~ /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/) {
|
||||||
|
grammar_error("job declaration must use a canonical block mapping")
|
||||||
|
next
|
||||||
|
}
|
||||||
|
validate_job_shape()
|
||||||
|
reset_job()
|
||||||
|
job = $0
|
||||||
|
sub(/^ /, "", job)
|
||||||
|
sub(/:.*/, "", job)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
in_jobs && job != "" {
|
||||||
|
raw = $0
|
||||||
|
line_indent = indentation(raw)
|
||||||
|
|
||||||
|
if (run_block != 0) {
|
||||||
|
if (raw ~ /^ *$/ || line_indent > 8) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
run_block = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw ~ /^ *#/) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
if (raw ~ /^ steps:/ || raw ~ /^ "steps":/ \
|
||||||
|
|| substr(raw, 1, 11) == " " single_quote "steps" single_quote ":") {
|
||||||
|
if (raw != " steps:") {
|
||||||
|
grammar_error("steps container must use a canonical block sequence")
|
||||||
|
next
|
||||||
|
}
|
||||||
|
steps_count++
|
||||||
|
if (steps_count != 1) {
|
||||||
|
grammar_error("must contain exactly one canonical steps block")
|
||||||
|
}
|
||||||
|
in_steps = 1
|
||||||
|
reset_step()
|
||||||
|
next
|
||||||
|
}
|
||||||
|
if (in_steps != 0 && line_indent == 4) {
|
||||||
|
in_steps = 0
|
||||||
|
reset_step()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw ~ /^ *<<:/) {
|
||||||
|
grammar_error("contains a forbidden merge key")
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_steps != 0 && raw ~ /^ - /) {
|
||||||
|
reset_step()
|
||||||
|
step_active = 1
|
||||||
|
content = substr(raw, 9)
|
||||||
|
validate_step_field(content)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_steps != 0 && raw ~ /^ -[[:space:]]*$/) {
|
||||||
|
grammar_error("contains unsupported empty step syntax")
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
|
||||||
|
content = substr(raw, 9)
|
||||||
|
validate_step_field(content)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_steps != 0 && line_indent == 6 && raw !~ /^ *$/) {
|
||||||
|
grammar_error("contains unsupported step-list syntax")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
END {
|
||||||
|
validate_job_shape()
|
||||||
|
if (jobs_count != 1) {
|
||||||
|
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
|
||||||
|
}
|
||||||
|
if (invalid) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "${workflow}"; then
|
||||||
|
fail "workflow structural validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq -- './gradlew' "${workflow}" \
|
||||||
|
&& ! grep -Fq -- 'gradle/actions/dependency-submission@' "${workflow}"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
((workflow_count += 1))
|
||||||
|
|
||||||
|
if ! jobs_in_workflow=$(
|
||||||
|
awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" \
|
||||||
|
-v validation_action="${EXPECTED_VALIDATION_ACTION}" \
|
||||||
|
-v dependency_action="${EXPECTED_DEPENDENCY_SUBMISSION_ACTION}" \
|
||||||
|
-v guarded_gradle_if="${EXPECTED_GUARDED_GRADLE_IF}" '
|
||||||
|
function reset_step(known_field) {
|
||||||
|
step_active = 0
|
||||||
|
run_block = 0
|
||||||
|
step_kind = ""
|
||||||
|
step_name = ""
|
||||||
|
step_id = ""
|
||||||
|
step_uses = ""
|
||||||
|
step_uses_action = ""
|
||||||
|
step_if = ""
|
||||||
|
step_if_present = 0
|
||||||
|
step_continue_on_error = 0
|
||||||
|
step_gradle = 0
|
||||||
|
step_gradle_line = 0
|
||||||
|
step_unsupported_gradle = 0
|
||||||
|
step_field_count = 0
|
||||||
|
step_name_line = 0
|
||||||
|
step_id_line = 0
|
||||||
|
step_uses_line = 0
|
||||||
|
step_extra_field = ""
|
||||||
|
for (known_field in step_fields) {
|
||||||
|
delete step_fields[known_field]
|
||||||
|
delete step_field_raw[known_field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset_job() {
|
||||||
|
job = ""
|
||||||
|
checkout_line = 0
|
||||||
|
validation_line = 0
|
||||||
|
gradle_line = 0
|
||||||
|
in_steps = 0
|
||||||
|
unsupported_gradle = 0
|
||||||
|
reset_step()
|
||||||
|
}
|
||||||
|
|
||||||
|
function indentation(line, first_non_space) {
|
||||||
|
if (line ~ /^ *$/) {
|
||||||
|
return length(line)
|
||||||
|
}
|
||||||
|
first_non_space = match(line, /[^ ]/)
|
||||||
|
return first_non_space - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function has_gradle_reference(line) {
|
||||||
|
return index(line, "./gradlew") != 0 \
|
||||||
|
|| index(line, "gradle/actions/dependency-submission@") != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function trim(value) {
|
||||||
|
sub(/^[[:space:]]+/, "", value)
|
||||||
|
sub(/[[:space:]]+$/, "", value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize_action(value, scalar, first, quote, closing, index_value) {
|
||||||
|
scalar = trim(value)
|
||||||
|
first = substr(scalar, 1, 1)
|
||||||
|
if (first == "\"" || first == single_quote) {
|
||||||
|
quote = first
|
||||||
|
closing = index(substr(scalar, 2), quote)
|
||||||
|
if (closing == 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return substr(scalar, 2, closing - 1)
|
||||||
|
}
|
||||||
|
sub(/[[:space:]]+#.*$/, "", scalar)
|
||||||
|
return trim(scalar)
|
||||||
|
}
|
||||||
|
|
||||||
|
function record_gradle(line_number) {
|
||||||
|
step_gradle = 1
|
||||||
|
if (step_gradle_line == 0) {
|
||||||
|
step_gradle_line = line_number
|
||||||
|
}
|
||||||
|
if (gradle_line == 0) {
|
||||||
|
gradle_line = line_number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function record_uses(value, line_number, action) {
|
||||||
|
if (step_kind == "run") {
|
||||||
|
if (index(value, "gradle/actions/dependency-submission@") != 0) {
|
||||||
|
step_unsupported_gradle = 1
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step_kind = "uses"
|
||||||
|
action = normalize_action(value)
|
||||||
|
step_uses = trim(value)
|
||||||
|
step_uses_action = action
|
||||||
|
step_uses_line = line_number
|
||||||
|
if (checkout_line == 0 && action ~ /^actions\/checkout@/) {
|
||||||
|
checkout_line = line_number
|
||||||
|
}
|
||||||
|
if (action == dependency_action) {
|
||||||
|
record_gradle(line_number)
|
||||||
|
} else if (index(action, "gradle/actions/dependency-submission@") != 0) {
|
||||||
|
record_gradle(line_number)
|
||||||
|
step_unsupported_gradle = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function record_run(value, line_number) {
|
||||||
|
if (step_kind == "uses") {
|
||||||
|
if (index(value, "./gradlew") != 0) {
|
||||||
|
step_unsupported_gradle = 1
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step_kind = "run"
|
||||||
|
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
|
||||||
|
run_block = 1
|
||||||
|
} else if (index(value, "./gradlew") != 0) {
|
||||||
|
record_gradle(line_number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function record_step_field(content, line_number, separator, field, value) {
|
||||||
|
separator = index(content, ":")
|
||||||
|
field = substr(content, 1, separator - 1)
|
||||||
|
value = substr(content, separator + 1)
|
||||||
|
sub(/^[[:space:]]*/, "", value)
|
||||||
|
step_fields[field] = 1
|
||||||
|
step_field_raw[field] = trim(content)
|
||||||
|
step_field_count++
|
||||||
|
|
||||||
|
if (field == "name") {
|
||||||
|
step_name = trim(value)
|
||||||
|
step_name_line = line_number
|
||||||
|
} else if (field == "id") {
|
||||||
|
step_id = trim(value)
|
||||||
|
step_id_line = line_number
|
||||||
|
} else if (field == "uses") {
|
||||||
|
record_uses(value, line_number)
|
||||||
|
} else if (field == "run") {
|
||||||
|
record_run(trim(value), line_number)
|
||||||
|
} else if (field == "if") {
|
||||||
|
step_if_present = 1
|
||||||
|
step_if = trim(value)
|
||||||
|
} else if (field == "continue-on-error") {
|
||||||
|
step_continue_on_error = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field != "name" && field != "id" && field != "uses" && step_extra_field == "") {
|
||||||
|
step_extra_field = step_field_raw[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate_wrapper_step() {
|
||||||
|
if (step_uses_action != validation_reference) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (step_extra_field != "") {
|
||||||
|
printf "%s: job %s wrapper validation step contains unsupported field: %s\n", workflow, job, step_extra_field > "/dev/stderr"
|
||||||
|
invalid = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (step_field_count != 3 \
|
||||||
|
|| step_name != "Validate Gradle wrapper" \
|
||||||
|
|| step_id != "gradle-wrapper-validation" \
|
||||||
|
|| step_uses != validation_action \
|
||||||
|
|| !(step_name_line < step_id_line && step_id_line < step_uses_line)) {
|
||||||
|
printf "%s: job %s wrapper validation step must contain exact name, id, and uses fields only\n", workflow, job > "/dev/stderr"
|
||||||
|
invalid = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (validation_line == 0) {
|
||||||
|
validation_line = step_uses_line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate_gradle_step() {
|
||||||
|
if (step_gradle == 0 && step_unsupported_gradle == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (step_unsupported_gradle != 0 || ("uses" in step_fields && "run" in step_fields)) {
|
||||||
|
unsupported_gradle = 1
|
||||||
|
}
|
||||||
|
if (step_if_present != 0 && step_if != guarded_gradle_if) {
|
||||||
|
printf "%s: job %s has Gradle step with unsupported if condition: %s\n", workflow, job, step_if > "/dev/stderr"
|
||||||
|
invalid = 1
|
||||||
|
}
|
||||||
|
if (step_continue_on_error != 0) {
|
||||||
|
printf "%s: job %s has Gradle step with unsupported field: %s\n", workflow, job, step_field_raw["continue-on-error"] > "/dev/stderr"
|
||||||
|
invalid = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalize_step() {
|
||||||
|
if (step_active == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
validate_wrapper_step()
|
||||||
|
validate_gradle_step()
|
||||||
|
}
|
||||||
|
|
||||||
|
function start_step() {
|
||||||
|
finalize_step()
|
||||||
|
reset_step()
|
||||||
|
step_active = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate_job() {
|
||||||
|
finalize_step()
|
||||||
|
if (job == "" || (gradle_line == 0 && unsupported_gradle == 0)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gradle_jobs++
|
||||||
|
if (unsupported_gradle != 0) {
|
||||||
|
printf "%s: job %s uses a Gradle invocation outside the canonical workflow structure\n", workflow, job > "/dev/stderr"
|
||||||
|
invalid = 1
|
||||||
|
}
|
||||||
|
if (gradle_line == 0) {
|
||||||
|
return
|
||||||
|
} else if (checkout_line == 0) {
|
||||||
|
printf "%s: job %s invokes Gradle without checkout\n", workflow, job > "/dev/stderr"
|
||||||
|
invalid = 1
|
||||||
|
} else if (validation_line == 0) {
|
||||||
|
printf "%s: job %s invokes Gradle without the exact pinned wrapper validation action\n", workflow, job > "/dev/stderr"
|
||||||
|
invalid = 1
|
||||||
|
} else if (!(checkout_line < validation_line && validation_line < gradle_line)) {
|
||||||
|
printf "%s: job %s must order checkout, exact wrapper validation, then Gradle\n", workflow, job > "/dev/stderr"
|
||||||
|
invalid = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BEGIN {
|
||||||
|
in_jobs = 0
|
||||||
|
invalid = 0
|
||||||
|
gradle_jobs = 0
|
||||||
|
single_quote = sprintf("%c", 39)
|
||||||
|
validation_reference = validation_action
|
||||||
|
sub(/[[:space:]]+#.*$/, "", validation_reference)
|
||||||
|
reset_job()
|
||||||
|
}
|
||||||
|
|
||||||
|
/^jobs:[[:space:]]*(#.*)?$/ {
|
||||||
|
in_jobs = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
in_jobs && /^[^[:space:]#]/ {
|
||||||
|
validate_job()
|
||||||
|
reset_job()
|
||||||
|
in_jobs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
in_jobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ {
|
||||||
|
validate_job()
|
||||||
|
reset_job()
|
||||||
|
job = $0
|
||||||
|
sub(/^ /, "", job)
|
||||||
|
sub(/:.*/, "", job)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
in_jobs && job != "" {
|
||||||
|
raw = $0
|
||||||
|
line_indent = indentation(raw)
|
||||||
|
|
||||||
|
if (run_block != 0) {
|
||||||
|
if (raw ~ /^ *$/) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
if (line_indent > 8) {
|
||||||
|
if (index(raw, "./gradlew") != 0) {
|
||||||
|
record_gradle(NR)
|
||||||
|
}
|
||||||
|
if (index(raw, "gradle/actions/dependency-submission@") != 0) {
|
||||||
|
step_unsupported_gradle = 1
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
run_block = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw ~ /^ *#/) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw == " steps:") {
|
||||||
|
in_steps = 1
|
||||||
|
reset_step()
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_steps != 0 && line_indent == 4) {
|
||||||
|
finalize_step()
|
||||||
|
in_steps = 0
|
||||||
|
reset_step()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_steps != 0 && raw ~ /^ - /) {
|
||||||
|
start_step()
|
||||||
|
content = substr(raw, 9)
|
||||||
|
record_step_field(content, NR)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
|
||||||
|
content = substr(raw, 9)
|
||||||
|
record_step_field(content, NR)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has_gradle_reference(raw)) {
|
||||||
|
unsupported_gradle = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
END {
|
||||||
|
validate_job()
|
||||||
|
print gradle_jobs
|
||||||
|
if (invalid) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "${workflow}"
|
||||||
|
); then
|
||||||
|
fail "workflow validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
|
||||||
|
fi
|
||||||
|
[[ "${jobs_in_workflow}" =~ ^[0-9]+$ ]] \
|
||||||
|
|| fail "workflow parser returned an invalid Gradle job count: ${workflow#"${REPOSITORY_ROOT}"/}"
|
||||||
|
((jobs_in_workflow > 0)) \
|
||||||
|
|| fail "Gradle-running workflow contains no detected Gradle job: ${workflow#"${REPOSITORY_ROOT}"/}"
|
||||||
|
((gradle_job_count += jobs_in_workflow))
|
||||||
|
done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0)
|
||||||
|
|
||||||
|
((workflow_count > 0)) || fail 'no Gradle-running workflow was found'
|
||||||
|
((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found'
|
||||||
|
((workflow_lock_valid != 0)) \
|
||||||
|
|| fail 'workflow lock mismatch: workflow set or bytes differ from the reviewed embedded manifest'
|
||||||
|
|
||||||
|
printf 'gradle-wrapper-contract: PASS\n'
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
name: ci-quality-gates
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality-gates:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- name: Require the committed public-path security baseline
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
readonly snapshot='docs/security/public-paths-snapshot.txt'
|
||||||
|
if [[ ! -s "${snapshot}" ]]; then
|
||||||
|
echo "::error::${snapshot} is missing or empty. CI must not let verifyPublicPathSnapshot create its own first-run baseline."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! git ls-files --error-unmatch "${snapshot}" >/dev/null 2>&1; then
|
||||||
|
echo "::error::${snapshot} exists locally but is not committed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Check quality, public paths, and dependency locks
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace
|
||||||
|
- name: Qualify opt-in inbound transports without skips
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew conditionalTransportQualification --no-daemon --stacktrace
|
||||||
|
|
||||||
|
sample-off:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Verify the application without the sample fixture
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --no-daemon --stacktrace
|
||||||
|
|
||||||
|
gate-matrix-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Verify the gate matrix against the repository
|
||||||
|
run: bash .github/scripts/verify-gate-matrix.sh
|
||||||
|
|
||||||
|
redis-sdk:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity,
|
||||||
|
# permit provenance, connection isolation, and the executor guard. There is no real-server
|
||||||
|
# lane yet — Tasks 10-17 add the contract suites that need one.
|
||||||
|
- name: Verify the Redis SDK policy, API parity, and guardrail contracts
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:shared-contract:edgeRateLimitContractTest
|
||||||
|
:adapter:outbound:cache-redis:check
|
||||||
|
verifyCleanArchitectureDependencies
|
||||||
|
verifyEnvKeys
|
||||||
|
verifyPublicPathSnapshot
|
||||||
|
verifyConfigurationPropertiesProcessor
|
||||||
|
--no-daemon --stacktrace
|
||||||
|
|
||||||
|
jpa-candidate-evidence:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Produce zero-skip JPA candidate manifests
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
- name: Retain content-addressed JPA candidate manifests
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||||
|
with:
|
||||||
|
name: jpa-candidate-evidence-${{ github.sha }}
|
||||||
|
path: src/adapter/outbound/persistence-jpa/build/jpa-evidence/manifests
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check.
|
||||||
|
quarantine:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run quarantined tests as an advisory signal
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew quarantineTest --no-daemon
|
||||||
|
|
||||||
|
release-gate:
|
||||||
|
needs:
|
||||||
|
- quality-gates
|
||||||
|
- sample-off
|
||||||
|
- gate-matrix-lint
|
||||||
|
- redis-sdk
|
||||||
|
- jpa-candidate-evidence
|
||||||
|
if: always()
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Require every current blocking job to succeed
|
||||||
|
env:
|
||||||
|
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
||||||
|
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
||||||
|
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
|
||||||
|
REDIS_RESULT: ${{ needs.redis-sdk.result }}
|
||||||
|
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for result in \
|
||||||
|
"${QUALITY_RESULT}" \
|
||||||
|
"${SAMPLE_OFF_RESULT}" \
|
||||||
|
"${MATRIX_RESULT}" \
|
||||||
|
"${REDIS_RESULT}" \
|
||||||
|
"${JPA_CANDIDATE_RESULT}"; do
|
||||||
|
if [[ "${result}" != "success" ]]; then
|
||||||
|
echo "::error::release-gate: required job result was ${result}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "release-gate: all current blocking quality jobs succeeded."
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
name: dependency-vulnerability
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The compare API exists only on GitHub.com. Trivy remains the full-snapshot backstop elsewhere.
|
||||||
|
dependency-review:
|
||||||
|
if: github.event_name == 'pull_request' && github.server_url == 'https://github.com'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Review newly introduced dependencies
|
||||||
|
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # actions/dependency-review-action@v4.8.0
|
||||||
|
with:
|
||||||
|
config-file: ./.github/dependency-review-config.yml
|
||||||
|
|
||||||
|
# The submission API is also GitHub.com-only and is not required for the platform-neutral scan.
|
||||||
|
dependency-submission:
|
||||||
|
if: github.event_name == 'push' && github.server_url == 'https://github.com'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Submit the resolved Gradle dependency graph
|
||||||
|
uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4
|
||||||
|
with:
|
||||||
|
build-root-directory: src
|
||||||
|
|
||||||
|
trivy-fs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Install pinned Trivy under RUNNER_TEMP
|
||||||
|
env:
|
||||||
|
TRIVY_DOWNLOAD_BASE_URL: ${{ vars.TRIVY_DOWNLOAD_BASE_URL }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
readonly TRIVY_VERSION='0.71.2'
|
||||||
|
readonly TRIVY_SHA256_AMD64='0510e71e2fd39bf863856d499c8dc19feb4e7336546394c502a8f5cc7ab27460'
|
||||||
|
readonly TRIVY_SHA256_ARM64='fe1c7106e15a5365d485b098a8c338f91e3b7ba71cb0e4963b98a3a098763cfc'
|
||||||
|
readonly DOWNLOAD_BASE_URL="${TRIVY_DOWNLOAD_BASE_URL:-https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}}"
|
||||||
|
case "${RUNNER_ARCH:-X64}" in
|
||||||
|
X64)
|
||||||
|
asset_arch='64bit'
|
||||||
|
expected_sha256="${TRIVY_SHA256_AMD64}"
|
||||||
|
;;
|
||||||
|
ARM64)
|
||||||
|
asset_arch='ARM64'
|
||||||
|
expected_sha256="${TRIVY_SHA256_ARM64}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "::error::unsupported runner architecture: ${RUNNER_ARCH:-unknown}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
install_dir="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}"
|
||||||
|
archive="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}.tar.gz"
|
||||||
|
mkdir -p "${install_dir}"
|
||||||
|
curl --fail --show-error --silent --location --retry 3 \
|
||||||
|
--proto '=https' --tlsv1.2 \
|
||||||
|
"${DOWNLOAD_BASE_URL}/trivy_${TRIVY_VERSION}_Linux-${asset_arch}.tar.gz" \
|
||||||
|
--output "${archive}"
|
||||||
|
printf '%s %s\n' "${expected_sha256}" "${archive}" | sha256sum -c -
|
||||||
|
tar -xzf "${archive}" -C "${install_dir}" trivy
|
||||||
|
chmod 0755 "${install_dir}/trivy"
|
||||||
|
printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}"
|
||||||
|
- name: Install checksum-pinned jq
|
||||||
|
env:
|
||||||
|
JQ_DOWNLOAD_BASE_URL: ${{ vars.JQ_DOWNLOAD_BASE_URL }}
|
||||||
|
run: bash .github/scripts/install-jq.sh
|
||||||
|
|
||||||
|
- name: Block High and Critical vulnerabilities
|
||||||
|
run: |
|
||||||
|
trivy fs \
|
||||||
|
--scanners vuln,license \
|
||||||
|
--severity CRITICAL,HIGH \
|
||||||
|
--exit-code 1 \
|
||||||
|
--ignorefile .trivyignore.yaml \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Report Medium and Low vulnerabilities
|
||||||
|
run: |
|
||||||
|
trivy fs \
|
||||||
|
--scanners vuln,license \
|
||||||
|
--severity MEDIUM,LOW \
|
||||||
|
--exit-code 0 \
|
||||||
|
--ignorefile .trivyignore.yaml \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Produce the governed all-severity KEV input
|
||||||
|
run: |
|
||||||
|
trivy fs \
|
||||||
|
--scanners vuln \
|
||||||
|
--severity CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN \
|
||||||
|
--exit-code 0 \
|
||||||
|
--ignorefile .trivyignore.yaml \
|
||||||
|
--format json \
|
||||||
|
--output trivy-kev.json \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Fail closed on any CISA KEV match
|
||||||
|
env:
|
||||||
|
CONFIGURED_KEV_FEED_URL: ${{ vars.KEV_FEED_URL }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
readonly DEFAULT_KEV_FEED_URL='https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json'
|
||||||
|
readonly KEV_FEED_URL="${CONFIGURED_KEV_FEED_URL:-${DEFAULT_KEV_FEED_URL}}"
|
||||||
|
if ! curl --fail --show-error --silent --location --retry 3 \
|
||||||
|
--proto '=https' --tlsv1.2 "${KEV_FEED_URL}" --output kev.json; then
|
||||||
|
echo "::error::KEV feed unavailable; configure KEV_FEED_URL to an approved internal mirror"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! jq -e '
|
||||||
|
(.catalogVersion | type == "string" and test("\\S"))
|
||||||
|
and (.dateReleased | type == "string" and test("\\S"))
|
||||||
|
and (.count | type == "number")
|
||||||
|
and (.count > 0)
|
||||||
|
and (.count == (.count | floor))
|
||||||
|
and (.vulnerabilities | type == "array")
|
||||||
|
and ((.vulnerabilities | length) > 0)
|
||||||
|
and (.count == (.vulnerabilities | length))
|
||||||
|
and (all(
|
||||||
|
.vulnerabilities[];
|
||||||
|
(.cveID | type == "string" and test("^CVE-[0-9]{4}-[0-9]{4,}$"))
|
||||||
|
))
|
||||||
|
and (([.vulnerabilities[].cveID] | unique | length) == .count)
|
||||||
|
' kev.json >/dev/null; then
|
||||||
|
echo "::error::KEV feed is malformed, empty, count-inconsistent, or contains invalid/duplicate cveID values"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! jq -e '
|
||||||
|
(type == "object")
|
||||||
|
and (.Results | type == "array")
|
||||||
|
and ((.Results | length) > 0)
|
||||||
|
and (all(.Results[]; type == "object"))
|
||||||
|
and (all(
|
||||||
|
.Results[];
|
||||||
|
(.Vulnerabilities == null) or (.Vulnerabilities | type == "array")
|
||||||
|
))
|
||||||
|
and (all(
|
||||||
|
.Results[];
|
||||||
|
all(
|
||||||
|
.Vulnerabilities[]?;
|
||||||
|
(type == "object")
|
||||||
|
and (.VulnerabilityID | type == "string" and test("\\S"))
|
||||||
|
)
|
||||||
|
))
|
||||||
|
' trivy-kev.json >/dev/null; then
|
||||||
|
echo "::error::Trivy KEV input is malformed, empty, or contains an invalid VulnerabilityID"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID | select(type == "string")] | unique[]?' \
|
||||||
|
trivy-kev.json | sort -u > found-cves.txt
|
||||||
|
jq -r '.vulnerabilities[]?.cveID | select(type == "string")' \
|
||||||
|
kev.json | sort -u > kev-cves.txt
|
||||||
|
hits="$(comm -12 found-cves.txt kev-cves.txt || true)"
|
||||||
|
if [[ -n "${hits}" ]]; then
|
||||||
|
echo "::error::CISA KEV-listed vulnerability found regardless of CVSS:"
|
||||||
|
printf '%s\n' "${hits}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "KEV cross-check: no catalog match."
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
name: fileserver-nightly
|
||||||
|
|
||||||
|
# The environments that cannot run on every pull request: a real network filesystem, a foreign
|
||||||
|
# filesystem, and the long-running fault matrices. They are nightly rather than skipped because a
|
||||||
|
# green pull-request run is not certification of any of them.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 18 * * *'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fileserver-nfs-ambiguity:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
FILESERVER_NFS_TESTS: "true"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Start the NFSv4 certification environment
|
||||||
|
run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait
|
||||||
|
- name: Run the network-filesystem ambiguity suite
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*NfsAmbiguityIntegrationTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
- name: Tear down the NFS environment
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f infra/fileserver/nfs/compose.yml down -v
|
||||||
|
|
||||||
|
fileserver-process-kill-matrix:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the crash matrix and reconciliation suites
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*CrashRecoveryMatrixTest'
|
||||||
|
:application-core:test --tests '*FileReconciliationServiceTest'
|
||||||
|
--rerun-tasks
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-large-file-performance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the large-file and slow-client suites under a constrained heap
|
||||||
|
working-directory: src
|
||||||
|
env:
|
||||||
|
GRADLE_OPTS: -Xmx512m
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
|
||||||
|
:adapter:outbound:fileserver:test --tests '*LocalAppendMemoryTest'
|
||||||
|
--rerun-tasks
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-multi-instance-lease:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Prove no run commits bytes from a stale lease
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:test --tests '*MultiInstanceWriterLeaseTest'
|
||||||
|
--rerun-tasks
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
name: fileserver-pr
|
||||||
|
|
||||||
|
# Every claim in docs/fileserver/support-matrix.md that says "Stable" is backed by a job here.
|
||||||
|
# A support level with no job behind it is a marketing claim, not an engineering one, and
|
||||||
|
# DocumentationCoverageTest fails the build when the two drift apart.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'src/application-core/src/**/fileserver/**'
|
||||||
|
- 'src/adapter/inbound/web/src/**/fileserver/**'
|
||||||
|
- 'src/adapter/outbound/fileserver/**'
|
||||||
|
- 'src/adapter/outbound/persistence-jpa/src/**/fileserver/**'
|
||||||
|
- 'src/app-bootstrap/src/**/fileserver/**'
|
||||||
|
- 'docs/fileserver/**'
|
||||||
|
# The capability is not only its Java files. A change to the bound settings, the shipped
|
||||||
|
# environment, the registry that documents it, or the container that has to give it a
|
||||||
|
# writable volume changes how it behaves at runtime just as surely — and those were the
|
||||||
|
# exact files that could previously ship unverified.
|
||||||
|
- 'src/app-bootstrap/src/main/resources/application.yml'
|
||||||
|
- 'src/.env'
|
||||||
|
- 'docs/registries/env-keys.yaml'
|
||||||
|
- 'src/Dockerfile'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
- 'infra/nginx/**'
|
||||||
|
- 'infra/k8s/**'
|
||||||
|
- '.github/workflows/fileserver-pr.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fileserver-unit-and-architecture:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the fileserver application and architecture suites
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:test
|
||||||
|
:app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*Fileserver*'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-local-ext4-contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Certify the local content store against the shared contract
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-http-contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the servlet and reactive transport contracts
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:web:test
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-security-suite:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the path, filename, range, and problem-detail hardening suite
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:web:test --tests '*FileserverHardeningContractTest'
|
||||||
|
:adapter:outbound:fileserver:test --tests '*PhysicalPathResolverTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-bounded-memory:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Prove transfer cost does not scale with file size
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
|
||||||
|
:adapter:inbound:web:test --tests '*DataBufferReleaseTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
name: fileserver-release
|
||||||
|
|
||||||
|
# The gate a release must clear. Its job list is deliberately the same shape as the support matrix:
|
||||||
|
# nothing may be advertised at a support level whose evidence job is absent here.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fileserver-full-verification:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the architecture-wide dependency and module verification
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
verifyCleanArchitectureDependencies
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
- name: Run the complete fileserver suite across every leaf
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:check
|
||||||
|
:adapter:inbound:web:check
|
||||||
|
:adapter:outbound:fileserver:check
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-documentation-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Prove every support claim maps to a job and every endpoint is documented
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:app-bootstrap:test --tests '*FileserverDocumentationCoverageTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-pvc-certification:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
# Two different things, kept apart on purpose. The manifest checks below run everywhere and
|
||||||
|
# fail on real drift; the cluster run needs a cluster and is skipped without one. The job
|
||||||
|
# used to `test -f` the manifest and report success, which read as "ReadWriteOnce certified"
|
||||||
|
# when nothing had been applied anywhere.
|
||||||
|
- name: Check the certification manifest still says what the claim depends on
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
manifest=infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
test -f "$manifest"
|
||||||
|
grep -q 'kind: PersistentVolumeClaim' "$manifest"
|
||||||
|
grep -q 'kind: Job' "$manifest"
|
||||||
|
# ReadWriteMany is explicitly not claimed; a manifest that quietly widened the access
|
||||||
|
# mode would certify a topology the support matrix says is uncertified.
|
||||||
|
grep -q 'ReadWriteOnce' "$manifest"
|
||||||
|
! grep -q 'ReadWriteMany' "$manifest"
|
||||||
|
- name: Certify the ReadWriteOnce claim on the release cluster
|
||||||
|
id: pvc-cluster-run
|
||||||
|
env:
|
||||||
|
KUBECONFIG_CONTENT: ${{ secrets.FILESERVER_PVC_KUBECONFIG }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${KUBECONFIG_CONTENT:-}" ]; then
|
||||||
|
echo "::warning::no release cluster configured; PVC certification was NOT run."
|
||||||
|
echo "The support matrix records this profile as Limited for exactly this reason:"
|
||||||
|
echo "the cluster result is produced by an operator against a real cluster and read"
|
||||||
|
echo "from docs/fileserver/storage-certification.md, not by this job."
|
||||||
|
echo "certified=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s' "$KUBECONFIG_CONTENT" > /tmp/kubeconfig
|
||||||
|
export KUBECONFIG=/tmp/kubeconfig
|
||||||
|
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
kubectl wait --for=condition=complete --timeout=30m job/fileserver-pvc-certification
|
||||||
|
kubectl logs job/fileserver-pvc-certification
|
||||||
|
echo "certified=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
fileserver-sensitive-telemetry-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Prove telemetry carries no filename, path, or raw identifier
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:test --tests '*FileserverObservabilityTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
name: httpclient-contract
|
||||||
|
|
||||||
|
# Per-PR gate for the HTTP Client Platform (design §29). Each transport runs the same semantic
|
||||||
|
# contract in its own job, so a transport that stops satisfying it fails on its own row instead of
|
||||||
|
# disappearing into an aggregate run.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'src/adapter/outbound/httpclient/**'
|
||||||
|
- 'src/app-bootstrap/src/**/httpclient/**'
|
||||||
|
- 'docs/httpclient/**'
|
||||||
|
- 'scripts/verify-httpclient-docs.py'
|
||||||
|
- '.github/workflows/httpclient-contract.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
httpclient-unit-and-boundaries:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the focused module suite and the architecture gate
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:test
|
||||||
|
verifyCleanArchitectureDependencies
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
httpclient-stable-contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
transport: [apache, jdk, reactor]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Certify one transport against the shared contract
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:httpClientStableContractTest
|
||||||
|
-Phttpclient.contract.transports=${{ matrix.transport }}
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
httpclient-security-and-compatibility:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the SSRF, cardinality, and Spring compatibility lanes
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:httpClientSecurityTest
|
||||||
|
:adapter:outbound:httpclient:httpClientBlockHoundTest
|
||||||
|
:adapter:outbound:httpclient:spring62ApiSurfaceScan
|
||||||
|
:adapter:outbound:httpclient:spring70CompatibilityTest
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
httpclient-composition:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Verify composition and architecture in the bootstrap module
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:app-bootstrap:test --tests '*httpclient*' --tests '*CleanArchitectureTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
name: httpclient-nightly
|
||||||
|
|
||||||
|
# Lanes that need a container runtime, real time, or a QUIC-capable host (design §29). They are
|
||||||
|
# separated from the per-PR gate rather than made optional inside it: a lane that cannot run here
|
||||||
|
# fails, it does not skip.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
httpclient-fault-injection:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Inject TCP faults against a real upstream
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:httpClientFailureInjectionTest
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
httpclient-performance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Certify pool, streaming, retry, and rotation bounds
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:httpClientPerformanceTest
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
httpclient-http3-experimental:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Experimental by design (D-08): the result is reported, never used to block a merge.
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Exercise the experimental HTTP/3 opt-in
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:test
|
||||||
|
-Phttp3.tests.enabled=true
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
name: httpclient-release
|
||||||
|
|
||||||
|
# Release gate for the HTTP Client Platform (design §38 step 4). Each declared gate runs as its own
|
||||||
|
# single-line `./gradlew <task>` step, because .github/scripts/verify-gate-matrix.sh reads these
|
||||||
|
# commands to prove the gate is actually executed — a folded or flag-laden command would make the
|
||||||
|
# declaration in .github/ci-gate-matrix.yml unverifiable.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src
|
||||||
|
env:
|
||||||
|
# A project property rather than a command-line flag, so each run command stays a plain,
|
||||||
|
# verifiable task invocation while the machine-dependent bounds are still asserted.
|
||||||
|
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Focused module tests
|
||||||
|
run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace
|
||||||
|
- name: Spring 6.2 API surface lane
|
||||||
|
run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace
|
||||||
|
- name: Spring 7.0 compatibility lane
|
||||||
|
run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace
|
||||||
|
- name: Stable cross-transport contract suite
|
||||||
|
run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace
|
||||||
|
- name: SSRF and cardinality suite
|
||||||
|
run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace
|
||||||
|
- name: Event-loop blocking suite
|
||||||
|
run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace
|
||||||
|
- name: Toxiproxy fault-injection suite
|
||||||
|
run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace
|
||||||
|
- name: Resource-bound performance certification
|
||||||
|
run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace
|
||||||
|
- name: Architecture dependency gate
|
||||||
|
run: ./gradlew verifyCleanArchitectureDependencies --no-daemon --stacktrace
|
||||||
|
|
||||||
|
httpclient-documentation:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
- name: Verify documentation matches the code
|
||||||
|
run: python3 scripts/verify-httpclient-docs.py
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: jpa-r2-evidence
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
jpa-r2-evidence:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
JPA_EVIDENCE_PROFILE: r2
|
||||||
|
JPA_EVIDENCE_CI_JOB: >-
|
||||||
|
actions:${{ github.workflow }}:${{ github.run_id }}:${{ github.job }}
|
||||||
|
JPA_EVIDENCE_ARTIFACT_LOCATION: >-
|
||||||
|
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Verify the production-profile JPA R2 manifest DAG
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence
|
||||||
|
-PjpaEvidenceProfile=r2
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
- name: Retain JPA R2 attempt manifests
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||||
|
with:
|
||||||
|
name: jpa-r2-evidence-${{ github.sha }}-${{ github.run_id }}
|
||||||
|
path: src/adapter/outbound/persistence-jpa/build/jpa-evidence/manifests
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: link-check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "README.md"
|
||||||
|
- "src/README.md"
|
||||||
|
- "src/**/README.md"
|
||||||
|
- "src/**/CLAUDE.md"
|
||||||
|
- "docs/**/*.md"
|
||||||
|
- ".github/**/*.md"
|
||||||
|
- ".github/workflows/link-check.yml"
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
paths:
|
||||||
|
- "README.md"
|
||||||
|
- "src/README.md"
|
||||||
|
- "src/**/README.md"
|
||||||
|
- "src/**/CLAUDE.md"
|
||||||
|
- "docs/**/*.md"
|
||||||
|
- ".github/**/*.md"
|
||||||
|
- ".github/workflows/link-check.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lychee:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Check committed documentation links
|
||||||
|
uses: lycheeverse/lychee-action@7cd0af4c74a61395d455af97419279d86aafaede # lycheeverse/lychee-action@v2.0.2
|
||||||
|
with:
|
||||||
|
args: >-
|
||||||
|
--no-progress
|
||||||
|
--root-dir .
|
||||||
|
README.md
|
||||||
|
src/README.md
|
||||||
|
'src/**/README.md'
|
||||||
|
'src/**/CLAUDE.md'
|
||||||
|
'docs/**/*.md'
|
||||||
|
'.github/**/*.md'
|
||||||
|
fail: true
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
name: object-storage-qualification
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: "23 3 * * 2"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
run_protected_aws:
|
||||||
|
description: Run the protected AWS sandbox qualification lane
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
poster-image-v7-migration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run non-skipping Poster image migration qualification
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
|
||||||
|
|
||||||
|
minio-managed-contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run exact-release MinIO managed contract
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --no-daemon --stacktrace
|
||||||
|
|
||||||
|
minio-managed-fault:
|
||||||
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run digest-pinned MinIO and Toxiproxy fault contract
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --no-daemon --stacktrace
|
||||||
|
|
||||||
|
aws-managed-common-subset:
|
||||||
|
if: github.event_name == 'workflow_dispatch' && inputs.run_protected_aws
|
||||||
|
environment: object-storage-aws-qualification
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED: "true"
|
||||||
|
OBJECT_STORAGE_AWS_BUCKET: ${{ secrets.OBJECT_STORAGE_AWS_BUCKET }}
|
||||||
|
OBJECT_STORAGE_AWS_REGION: ${{ secrets.OBJECT_STORAGE_AWS_REGION }}
|
||||||
|
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run protected AWS common-subset qualification
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --no-daemon --stacktrace
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Redis SDK topology evidence.
|
||||||
|
#
|
||||||
|
# The lanes in infra/redis-sdk answer what the deterministic in-memory gateway cannot — Sentinel
|
||||||
|
# promotion behaviour, Cluster redirects, ACL coverage. docs/redis/support-matrix.md records which
|
||||||
|
# lane produced which evidence, and RedisSupportMatrixTest refuses an evidence claim that does not
|
||||||
|
# name the test class behind it.
|
||||||
|
#
|
||||||
|
# Three cadences, because the cost and the question differ:
|
||||||
|
#
|
||||||
|
# pull_request standalone only, current supported version. The cheapest lane that can still
|
||||||
|
# catch "this change cannot talk to a real Redis at all". A PR gate that starts
|
||||||
|
# three topologies is a PR gate people learn to ignore.
|
||||||
|
# schedule the full supported-version x topology matrix, nightly. This is where Sentinel
|
||||||
|
# promotion and Cluster redirect evidence comes from.
|
||||||
|
# workflow_dispatch one lane on demand, for reproducing a specific failure.
|
||||||
|
#
|
||||||
|
# A release candidate uses the nightly matrix run for its tag: `release-candidate` selects the full
|
||||||
|
# matrix on demand so an RC does not have to wait for the next scheduled run.
|
||||||
|
#
|
||||||
|
# Each lane has its own endpoint. A sentinel is not a data node and a cluster node is not the whole
|
||||||
|
# cluster, so the address, port, and (for Sentinel) the monitored primary's name are per-lane rather
|
||||||
|
# than one hardcoded 6379 that happens to be right for standalone only.
|
||||||
|
#
|
||||||
|
# The Gradle task is fail-closed on its own account: an unknown mode, a missing endpoint, a lane
|
||||||
|
# with no tagged test class, and a run that executed zero tests are all errors. This workflow does
|
||||||
|
# not need to re-check those, but it does have to keep the evidence, which is why every run uploads
|
||||||
|
# the JUnit XML together with the commit SHA, the server version and the resolved image digest. An
|
||||||
|
# evidence artifact that cannot say which image produced it is not evidence.
|
||||||
|
name: redis-sdk-topology
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "src/adapter/outbound/cache-redis/**"
|
||||||
|
- "infra/redis-sdk/**"
|
||||||
|
- ".github/workflows/redis-sdk-topology.yml"
|
||||||
|
schedule:
|
||||||
|
# 02:30 UTC daily. Nightly, not hourly: the matrix starts real servers.
|
||||||
|
- cron: "30 2 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
topology:
|
||||||
|
description: standalone, sentinel, cluster, tls, or release-candidate for the full matrix
|
||||||
|
required: true
|
||||||
|
default: standalone
|
||||||
|
type: choice
|
||||||
|
options: [standalone, sentinel, cluster, tls, release-candidate]
|
||||||
|
redis_version:
|
||||||
|
description: server version tag
|
||||||
|
required: true
|
||||||
|
default: "7.4"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The matrix is computed rather than duplicated per trigger, so adding a supported version is one
|
||||||
|
# edit and no trigger can silently keep testing an old set.
|
||||||
|
lanes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
matrix: ${{ steps.select.outputs.matrix }}
|
||||||
|
steps:
|
||||||
|
- id: select
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case "${{ github.event_name }}" in
|
||||||
|
pull_request)
|
||||||
|
matrix='{"include":[{"topology":"standalone","redis_version":"7.4"}]}'
|
||||||
|
;;
|
||||||
|
schedule)
|
||||||
|
matrix='{"include":[
|
||||||
|
{"topology":"standalone","redis_version":"7.2"},
|
||||||
|
{"topology":"standalone","redis_version":"7.4"},
|
||||||
|
{"topology":"standalone","redis_version":"8.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.4"},
|
||||||
|
{"topology":"sentinel","redis_version":"8.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.4"},
|
||||||
|
{"topology":"cluster","redis_version":"8.2"},
|
||||||
|
{"topology":"tls","redis_version":"7.4"},
|
||||||
|
{"topology":"tls","redis_version":"8.2"}]}'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [ "${{ inputs.topology }}" = "release-candidate" ]; then
|
||||||
|
matrix='{"include":[
|
||||||
|
{"topology":"standalone","redis_version":"7.2"},
|
||||||
|
{"topology":"standalone","redis_version":"7.4"},
|
||||||
|
{"topology":"standalone","redis_version":"8.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.4"},
|
||||||
|
{"topology":"sentinel","redis_version":"8.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.4"},
|
||||||
|
{"topology":"cluster","redis_version":"8.2"},
|
||||||
|
{"topology":"tls","redis_version":"7.4"},
|
||||||
|
{"topology":"tls","redis_version":"8.2"}]}'
|
||||||
|
else
|
||||||
|
matrix='{"include":[{"topology":"${{ inputs.topology }}","redis_version":"${{ inputs.redis_version }}"}]}'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf 'matrix=%s\n' "$(printf '%s' "$matrix" | tr -d '\n ')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
topology-evidence:
|
||||||
|
needs: lanes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.lanes.outputs.matrix) }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Start the topology
|
||||||
|
env:
|
||||||
|
REDIS_VERSION: ${{ matrix.redis_version }}
|
||||||
|
run: docker compose -f "infra/redis-sdk/${{ matrix.topology }}/compose.yml" up -d --wait
|
||||||
|
- name: Record the image digest
|
||||||
|
id: image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# The tag says 7.4; the digest says which 7.4. Evidence that names only the tag cannot be
|
||||||
|
# reproduced once the tag moves.
|
||||||
|
digest="$(docker image inspect --format '{{index .RepoDigests 0}}' \
|
||||||
|
"redis:${{ matrix.redis_version }}" 2>/dev/null || echo 'unresolved')"
|
||||||
|
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Run the topology contracts
|
||||||
|
working-directory: src
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case '${{ matrix.topology }}' in
|
||||||
|
standalone) port=6379; extra='' ;;
|
||||||
|
sentinel) port=27010; extra='-Predis.topology.master=skeleton' ;;
|
||||||
|
cluster) port=7100; extra='' ;;
|
||||||
|
# The TLS lane's CA is generated at start-up, so the trust material is extracted from
|
||||||
|
# the lane rather than checked in. A checked-in key is a secret in the repository
|
||||||
|
# however loudly the file is named "test".
|
||||||
|
tls)
|
||||||
|
port=6390
|
||||||
|
docker compose -f ../infra/redis-sdk/tls/compose.yml cp redis:/tls/ca.crt "$RUNNER_TEMP/redis-lane-ca.pem"
|
||||||
|
extra="-Predis.topology.trust-material=$RUNNER_TEMP/redis-lane-ca.pem"
|
||||||
|
;;
|
||||||
|
*) echo "unknown topology"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
./gradlew :adapter:outbound:cache-redis:redisTopologyTest --console=plain \
|
||||||
|
-Predis.topology.host=localhost \
|
||||||
|
-Predis.topology.port="$port" \
|
||||||
|
-Predis.topology.mode='${{ matrix.topology }}' \
|
||||||
|
$extra
|
||||||
|
- name: Write the evidence manifest
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
out=src/adapter/outbound/cache-redis/build/test-results/redisTopologyTest
|
||||||
|
mkdir -p "$out"
|
||||||
|
cat > "$out/evidence-manifest.txt" <<MANIFEST
|
||||||
|
commit=${{ github.sha }}
|
||||||
|
workflow_run=${{ github.run_id }}
|
||||||
|
trigger=${{ github.event_name }}
|
||||||
|
topology=${{ matrix.topology }}
|
||||||
|
redis_version=${{ matrix.redis_version }}
|
||||||
|
image_digest=${{ steps.image.outputs.digest }}
|
||||||
|
MANIFEST
|
||||||
|
- name: Preserve the evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: redis-topology-${{ matrix.topology }}-${{ matrix.redis_version }}
|
||||||
|
path: |
|
||||||
|
src/adapter/outbound/cache-redis/build/test-results/redisTopologyTest/**
|
||||||
|
src/adapter/outbound/cache-redis/build/reports/tests/redisTopologyTest/**
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
- name: Stop the topology
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f "infra/redis-sdk/${{ matrix.topology }}/compose.yml" down -v
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.vscode/
|
||||||
|
src/**/bin/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
java temurin-21.0.11+10
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Structured Trivy suppression baseline.
|
||||||
|
#
|
||||||
|
# This repository-root file is the only CI suppression source. Every future entry must include:
|
||||||
|
# id: advisory, license, misconfiguration, or secret identifier
|
||||||
|
# statement: non-empty accepted-risk or false-positive rationale
|
||||||
|
# expired_at: future YYYY-MM-DD no more than 90 days from review
|
||||||
|
#
|
||||||
|
# `verifyTrivyignore` enforces those fields and the expiry window. CODEOWNERS supplies the separate
|
||||||
|
# reviewer control. Every Trivy invocation must also name this file with
|
||||||
|
# `--ignorefile .trivyignore.yaml`; do not add ad-hoc ignore files or inline bypasses.
|
||||||
|
|
||||||
|
vulnerabilities: []
|
||||||
|
licenses: []
|
||||||
|
misconfigurations: []
|
||||||
|
secrets: []
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## 프로젝트 정체성
|
||||||
|
|
||||||
|
이 저장소는 단순한 예제 블로그 애플리케이션이 아니라, Java 21 + Spring Boot 4.0.0 + Gradle 멀티모듈 기반의 Clean Architecture 템플릿이다.
|
||||||
|
|
||||||
|
기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 엔지니어링 작업 기록 게시판)에 격리한다. 새 프로젝트를 시작할 때는 도메인 이름, 패키지, 엔티티, 유스케이스를 교체할 수 있지만, 모듈 경계와 의존성 방향은 유지해야 한다.
|
||||||
|
|
||||||
|
## Prime Directive
|
||||||
|
|
||||||
|
에이전트는 속도보다 아키텍처 보존을 우선한다.
|
||||||
|
|
||||||
|
동작하는 코드라도 HARD-STOP 조건을 하나라도 위반하면 완료된 작업이 아니다.
|
||||||
|
|
||||||
|
다음 HARD-STOP 8개 항목이 이 저장소의 정본(canonical) 로컬 정책 권위이자 SSOT다.
|
||||||
|
|
||||||
|
1. `domain-core`가 framework, transport, database, cloud 의존성을 가진다.
|
||||||
|
2. controller가 repository, Spring Data interface, persistence entity를 직접 사용한다.
|
||||||
|
3. inbound DTO가 `application-core` 또는 `domain-core`로 유출된다.
|
||||||
|
4. 비즈니스 규칙이 mapper, filter, configuration, settings, controller로 이동한다.
|
||||||
|
5. 프로젝트 의존성이 `src/config/architecture/modules.json` 또는 Gradle 의존성 검증을 위반한다.
|
||||||
|
6. 관련 검증 없이, 또는 실행하지 못한 이유를 밝히지 않고 완료를 주장한다.
|
||||||
|
7. 결론의 범위와 위험에 맞는 증거 없이 repository/corpus 전체 결론을 내린다.
|
||||||
|
8. 의미 있는 작업을 필수 LLM Wiki capture 또는 명시한 capture 차단 사유 없이 종료한다.
|
||||||
|
|
||||||
|
root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어긋나면 이 `AGENTS.md` 목록이
|
||||||
|
우선한다. 자동 강제 범위는 아래 Gradle 정책 권위와 ArchUnit/Test가 담당한다.
|
||||||
|
|
||||||
|
## Superpowers Workflow
|
||||||
|
|
||||||
|
이 프로젝트에서 에이전트는 관련 Superpowers 스킬을 먼저 확인하고, 작업 성격에 맞는 스킬을 사용한다. 사용자 지시와 이 `AGENTS.md`가 로컬 프로젝트의 최상위 규칙이며, Superpowers는 그 규칙을 실행하기 위한 작업 방식이다.
|
||||||
|
|
||||||
|
사용 가능한 주요 스킬과 트리거:
|
||||||
|
|
||||||
|
- `superpowers:using-superpowers`: 대화나 작업을 시작할 때 관련 스킬을 확인한다.
|
||||||
|
- `superpowers:brainstorming`: 기능 설계, 구조 변경, 동작 변경, 새 문서 정책 수립 전에 사용한다.
|
||||||
|
- `superpowers:writing-plans`: 승인된 설계가 있고 작업이 여러 단계로 나뉠 때 사용한다.
|
||||||
|
- `superpowers:executing-plans`: 작성된 계획을 현재 세션에서 순차 실행할 때 사용한다.
|
||||||
|
- `superpowers:subagent-driven-development`: 계획을 작업 단위로 나누어 독립 에이전트에게 맡길 때 사용한다.
|
||||||
|
- `superpowers:dispatching-parallel-agents`: 서로 독립적인 조사나 구현을 병렬로 진행할 때 사용한다.
|
||||||
|
- `superpowers:test-driven-development`: 기능 추가와 버그 수정을 테스트 우선으로 진행할 때 사용한다.
|
||||||
|
- `superpowers:systematic-debugging`: 버그, 실패한 테스트, 예상 밖 동작을 다룰 때 사용한다.
|
||||||
|
- `superpowers:verification-before-completion`: 완료, 수정됨, 통과함을 주장하기 전에 사용한다.
|
||||||
|
- `superpowers:requesting-code-review`: 의미 있는 구현을 마친 뒤 병합 또는 PR 전에 사용한다.
|
||||||
|
- `superpowers:receiving-code-review`: 리뷰 피드백을 적용하기 전에 사용한다.
|
||||||
|
- `superpowers:finishing-a-development-branch`: 구현과 검증이 끝난 브랜치를 정리할 때 사용한다.
|
||||||
|
- `superpowers:using-git-worktrees`: 격리된 작업 공간이 필요할 때 사용한다.
|
||||||
|
- `superpowers:writing-skills`: 스킬을 만들거나 수정할 때 사용한다.
|
||||||
|
|
||||||
|
## Gradle 정책 권위
|
||||||
|
|
||||||
|
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
|
||||||
|
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
|
||||||
|
membership
|
||||||
|
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
|
||||||
|
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
|
||||||
|
architecture-wide verification task
|
||||||
|
|
||||||
|
작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md`를
|
||||||
|
함께 읽는다. focused test는 registry의 `gradle_path`에서
|
||||||
|
`./gradlew <gradle-path>:test --console=plain` 형태로 파생한다. 파일 수만으로 위험을 판단하지 않고,
|
||||||
|
변경한 경계와 런타임·보안·데이터 영향에 맞춰 설계·리뷰·검증 강도를 높인다.
|
||||||
|
|
||||||
|
commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit/amend/push하지 않는다.
|
||||||
|
|
||||||
|
## LLM Wiki 캡처 워크플로우
|
||||||
|
|
||||||
|
구현, 아키텍처, 빌드, 테스트, 런타임, 문서 워크플로우 변경처럼 의미 있는 작업을 끝낸 뒤에는 최종 응답 전에 LLM Wiki 기록을 갱신한다.
|
||||||
|
|
||||||
|
기준 vault:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/donghyeon/workspace/ai-tool/llm-wiki-private/
|
||||||
|
```
|
||||||
|
|
||||||
|
에이전트는 해당 vault의 `AGENTS.md`, `CLAUDE.md`, `rules/`, `.agents/`, `.claude/`, `.codex/` 지침을 확인한 뒤 작성한다.
|
||||||
|
|
||||||
|
필수 순서:
|
||||||
|
|
||||||
|
1. `/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/<branch-name>.md`를 생성하거나 갱신한다.
|
||||||
|
2. 구현 내용, 변경 파일, 의사결정, 검증 명령, 실패/차단 사항, 증거 등급을 branch-note에 기록한다.
|
||||||
|
3. 실제로 파생 자료가 있으면 다음 raw 문서를 생성하거나 갱신한다.
|
||||||
|
- `raw/errors/`: 오류, 실패한 테스트, 샌드박스/도구 문제, 재발 가능한 트러블슈팅
|
||||||
|
- `raw/interviews/`: 이 작업에서 정직하게 뽑을 수 있는 면접 질문
|
||||||
|
- `raw/blog-topics/`: 채용공고가 아닌 구현·설계·트러블슈팅 기반 블로그 글감
|
||||||
|
4. 파생 문서는 `## Parent`에서 branch-note로 upward link하고, branch-note의 `## Cluster / 묶음`에는 파생 문서 wikilink를 되돌려 적는다.
|
||||||
|
5. canonical 추출 요청이 없는 한 `wiki/blog/`, `wiki/interview/`, `wiki/portfolio/`, `wiki/concepts/`, `wiki/projects/`를 바로 만들지 않는다.
|
||||||
|
|
||||||
|
파생 문서가 필요 없을 때도 그냥 생략하지 말고, branch-note의 cluster 섹션에 "없음" 또는 "추출할 별도 글감 없음"처럼 판단 결과를 남긴다.
|
||||||
|
|
||||||
|
## 강제 수준
|
||||||
|
|
||||||
|
이 문서는 에이전트와 개발자가 따라야 할 작업 규칙을 정의하지만, 그 자체로 빌드나 테스트를 실패시키는 자동 강제 장치는 아니다.
|
||||||
|
|
||||||
|
이 프로젝트의 규칙은 세 단계로 관리한다.
|
||||||
|
|
||||||
|
1. `AGENTS.md`: 에이전트와 개발자가 따라야 할 아키텍처, 작업 순서, 검증 원칙을 정의한다.
|
||||||
|
2. ArchUnit/Gradle/Test: 모듈 의존성, 계층 침범, 설정 바인딩, 동작 회귀를 자동으로 탐지한다.
|
||||||
|
3. Code Review/CI: 문서와 자동 검증이 놓친 설계 품질, 운영 위험, 템플릿 일관성을 최종 확인한다.
|
||||||
|
|
||||||
|
에이전트는 `AGENTS.md`를 따라 작업해야 하며, 현업 수준의 스켈레톤 완성도는 반드시 자동 검증과 리뷰로 보강해야 한다.
|
||||||
|
|
||||||
|
## 모듈 책임
|
||||||
|
|
||||||
|
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
|
||||||
|
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
|
||||||
|
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
|
||||||
|
`src/**/CLAUDE.md`를 함께 읽는다.
|
||||||
|
|
||||||
|
- `domain-core`: 순수 도메인 모델, 불변식, 이벤트, port. Spring/JPA/transport/IO 타입 금지.
|
||||||
|
- `application-core`: command, use case, application policy, transaction port, application 예외.
|
||||||
|
inbound DTO, persistence entity, adapter 타입 금지.
|
||||||
|
- `adapter:inbound:*`: HTTP/gRPC/GraphQL/WebSocket transport, DTO, validation, 인증·인가 매핑,
|
||||||
|
error mapping. repository 직접 호출과 비즈니스 정책 금지.
|
||||||
|
- `adapter:outbound:persistence-*`: JPA/PostgreSQL 또는 MongoDB persistence 구현과 mapping,
|
||||||
|
migration/vendor 동작. 유스케이스 정책 금지.
|
||||||
|
- 그 밖의 `adapter:outbound:*`: support, messaging, cache, notification, object storage,
|
||||||
|
file server, HTTP client, identifier 능력을 port 뒤에서 구현한다. adapter 간 허용 edge는
|
||||||
|
registry만 따른다.
|
||||||
|
- `shared-contract`: skeleton-wide 운영 계약. business/domain 개념 저장 금지.
|
||||||
|
- `sample-portfolio`: 샘플/fixture consumer. production leaf가 의존하면 안 된다.
|
||||||
|
- `app-bootstrap`: Spring Boot entrypoint와 composition root. 비즈니스 유스케이스 금지.
|
||||||
|
|
||||||
|
## 의존성 방향
|
||||||
|
|
||||||
|
family 수준 기본 방향:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app-bootstrap -> adapter:inbound:* -> application-core -> domain-core
|
||||||
|
app-bootstrap -> adapter:outbound:* -> application-core -> domain-core
|
||||||
|
runtime modules -> shared-contract
|
||||||
|
sample-portfolio -> registered runtime leaves (fixture consumer only)
|
||||||
|
```
|
||||||
|
|
||||||
|
개별 edge는 `src/config/architecture/modules.json`의 `allowed_dependencies`가 유일한 목록이다.
|
||||||
|
Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억에서 leaf edge를 추론하지 않는다.
|
||||||
|
|
||||||
|
## 기능 개발 프로토콜
|
||||||
|
|
||||||
|
새 기능이나 동작 변경은 다음 순서로 진행한다.
|
||||||
|
|
||||||
|
1. 요구사항을 읽고 소유 계층을 판단한다.
|
||||||
|
2. 구조나 동작이 바뀌면 `superpowers:brainstorming`으로 설계를 먼저 확정한다.
|
||||||
|
3. 여러 단계의 작업이면 `docs/superpowers/specs/`에 설계 문서를 남긴다.
|
||||||
|
4. 구현 전 `docs/superpowers/plans/`에 실행 계획을 작성한다.
|
||||||
|
5. 동작 변경은 테스트를 먼저 작성한다.
|
||||||
|
6. 새 비즈니스 개념은 `domain-core`에 먼저 둔다.
|
||||||
|
7. 유스케이스는 `application-core` command와 use case/service method로 표현한다.
|
||||||
|
8. 외부 연동은 registry가 가리키는 `adapter:outbound:*` leaf에서 port 구현으로 추가한다.
|
||||||
|
9. endpoint는 마지막에 해당 `adapter:inbound:*` leaf에서 얇게 연결한다.
|
||||||
|
10. focused test를 먼저 돌리고, 가능한 경우 전체 Gradle test를 돌린다.
|
||||||
|
11. LLM Wiki branch-note와 필요한 파생 raw 문서를 갱신한다.
|
||||||
|
12. 완료 응답에는 변경 파일, 검증 결과, Wiki capture 결과를 포함한다.
|
||||||
|
|
||||||
|
## 테스트 전략
|
||||||
|
|
||||||
|
계층별 테스트 기준:
|
||||||
|
|
||||||
|
- `domain-core`: Spring 없이 순수 unit test로 도메인 규칙을 검증한다.
|
||||||
|
- `application-core`: fake/in-memory port를 사용해 유스케이스 흐름을 검증한다.
|
||||||
|
- `adapter:inbound:*`: transport validation, auth mapping, status/response contract를 검증한다.
|
||||||
|
- `adapter:outbound:persistence-*`: persistence mapping, repository adapter, vendor/migration 동작을 검증한다.
|
||||||
|
- 그 밖의 `adapter:outbound:*`: external capability adapter contract를 검증한다.
|
||||||
|
- `app-bootstrap/settings`: configuration binding, validation, logging 설정을 검증한다.
|
||||||
|
|
||||||
|
검증 원칙:
|
||||||
|
|
||||||
|
- 완료를 주장하기 전에 `superpowers:verification-before-completion`을 사용한다.
|
||||||
|
- 실행한 명령과 결과를 최종 응답에 적는다.
|
||||||
|
- 테스트를 실행하지 못했다면 이유와 남은 위험을 솔직히 적는다.
|
||||||
|
|
||||||
|
권장 절차:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew <owner-gradle-path>:test --console=plain
|
||||||
|
./gradlew test
|
||||||
|
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle)
|
||||||
|
./gradlew verifyCleanArchitectureDependencies
|
||||||
|
./gradlew verifyPublicPathSnapshot
|
||||||
|
./gradlew verifyEnvKeys
|
||||||
|
```
|
||||||
|
|
||||||
|
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
|
||||||
|
명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다.
|
||||||
|
|
||||||
|
## 설정과 런타임
|
||||||
|
|
||||||
|
설정 규칙:
|
||||||
|
|
||||||
|
- secrets를 코드, 테스트 fixture, 문서 예시에 하드코딩하지 않는다.
|
||||||
|
- 새 설정 그룹은 typed settings class로 만든다.
|
||||||
|
- 흩어진 `@Value`보다 configuration properties와 settings class를 선호한다.
|
||||||
|
- `.env.local`은 로컬 오버라이드로 취급한다.
|
||||||
|
- `application.yml`은 환경별로 안전한 기본값과 명확한 placeholder만 담는다.
|
||||||
|
- 새 settings class를 만들면 binding/validation 테스트를 추가한다.
|
||||||
|
|
||||||
|
Docker/runtime 규칙:
|
||||||
|
|
||||||
|
- Dockerfile 변경 시 build context와 runtime env 요구사항을 함께 확인한다.
|
||||||
|
- container 안에서 필요한 profile, port, env var를 문서나 예시 설정에 반영한다.
|
||||||
|
- local-only 경로와 운영 경로를 섞지 않는다.
|
||||||
|
|
||||||
|
## 템플릿 재사용 체크리스트
|
||||||
|
|
||||||
|
이 저장소를 새 프로젝트 시작점으로 사용할 때:
|
||||||
|
|
||||||
|
1. `src/settings.gradle`의 `rootProject.name`을 새 프로젝트명으로 바꾼다.
|
||||||
|
2. Java package `dev.caskeleton`을 새 organization/project package로 바꾼다.
|
||||||
|
3. `CaSkeletonApplication` 이름을 새 애플리케이션 이름으로 바꾼다.
|
||||||
|
4. production 모듈에는 목표 도메인의 entity, repository port, use case, adapter만 추가하고, 예시 코드는 `sample-portfolio`에 격리한다.
|
||||||
|
5. 모듈 이름과 경계는 유지한다.
|
||||||
|
6. Docker image/application 이름을 새 프로젝트 기준으로 수정한다.
|
||||||
|
7. `.env`, `.env.local`, `application.yml`의 예시 값을 새 런타임 요구사항에 맞춘다.
|
||||||
|
8. README와 운영 문서를 새 프로젝트 설명으로 갱신한다.
|
||||||
|
9. 전체 테스트를 실행한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 금지된 지름길
|
||||||
|
|
||||||
|
에이전트는 다음을 하지 않는다.
|
||||||
|
|
||||||
|
- `domain-core`에 Spring/JPA annotation 추가
|
||||||
|
- controller에서 repository 직접 호출
|
||||||
|
- application method가 web request DTO를 인자로 받게 만들기
|
||||||
|
- application이나 domain에서 JPA entity 반환
|
||||||
|
- mapper에 비즈니스 정책 넣기
|
||||||
|
- filter/config/settings class에 유스케이스 넣기
|
||||||
|
- 요청 범위 밖의 대규모 리팩터링
|
||||||
|
- 사용자 변경사항 되돌리기
|
||||||
|
- 명시적 요청 없는 destructive git command 실행
|
||||||
|
- 테스트 미실행 상태에서 "완료"라고 말하기
|
||||||
|
|
||||||
|
## 작업 보고 규칙
|
||||||
|
|
||||||
|
최종 응답에는 다음을 포함한다.
|
||||||
|
|
||||||
|
- 변경한 파일
|
||||||
|
- 핵심 변경 내용
|
||||||
|
- 실행한 검증 명령
|
||||||
|
- 실패하거나 실행하지 못한 검증
|
||||||
|
- LLM Wiki branch-note와 파생 문서 캡처 결과
|
||||||
|
- 남은 위험 또는 후속 작업
|
||||||
|
|
||||||
|
짧은 작업이라도 검증 여부는 생략하지 않는다.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture template.
|
||||||
|
|
||||||
|
## Prime Directive
|
||||||
|
|
||||||
|
Preserve architecture before optimizing for speed. The following eight HARD-STOP conditions are a
|
||||||
|
synchronized summary of the canonical local policy in `AGENTS.md`:
|
||||||
|
|
||||||
|
1. `domain-core` gains framework, transport, database, or cloud dependencies.
|
||||||
|
2. A controller directly uses a repository, Spring Data interface, or persistence entity.
|
||||||
|
3. An inbound DTO leaks into `application-core` or `domain-core`.
|
||||||
|
4. Business rules move into mappers, filters, configuration, settings, or controllers.
|
||||||
|
5. Project dependencies violate `src/config/architecture/modules.json` or the Gradle dependency
|
||||||
|
gate.
|
||||||
|
6. Completion is claimed without the relevant verification or a named reason it could not run.
|
||||||
|
7. A repository/corpus conclusion is made without evidence proportional to its scope and risk.
|
||||||
|
8. Non-trivial work closes without the required LLM Wiki capture or a reported capture block.
|
||||||
|
|
||||||
|
If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must be resynchronized.
|
||||||
|
|
||||||
|
## Gradle policy authorities
|
||||||
|
|
||||||
|
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
|
||||||
|
paths, Gradle paths, allowed production project dependency edges, and the exact runtime
|
||||||
|
memberships of both composition roots.
|
||||||
|
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
|
||||||
|
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
|
||||||
|
verification tasks.
|
||||||
|
|
||||||
|
Commit policy is `human-only`: agents do not stage, commit, amend, or push implementation changes.
|
||||||
|
|
||||||
|
## Proportional workflow
|
||||||
|
|
||||||
|
- Low risk: work in the owning leaf, follow its nearest guidance, and run the focused check.
|
||||||
|
- Medium risk: use the relevant Superpowers design, planning, TDD, debugging, and review workflows
|
||||||
|
in proportion to the affected boundaries.
|
||||||
|
- High risk: make architecture and behavior decisions explicit, use staged architecture/spec/quality
|
||||||
|
review, and run architecture-wide verification authorized by the task.
|
||||||
|
|
||||||
|
Risk comes from change surface and runtime, security, data, or public-contract impact, not file
|
||||||
|
count.
|
||||||
|
|
||||||
|
## Module families
|
||||||
|
|
||||||
|
`src/config/architecture/modules.json` owns the complete 19-leaf list. Root guidance summarizes
|
||||||
|
families; the nearest `src/**/CLAUDE.md` owns local rules.
|
||||||
|
|
||||||
|
| Family | Responsibility | Stable dependency direction |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `domain-core` | Pure domain model, invariants, events, ports | Java stdlib and registered value-only contracts |
|
||||||
|
| `application-core` | Commands, use cases, application policies, transaction ports | `domain-core`, `shared-contract` |
|
||||||
|
| `adapter:inbound:*` | HTTP, gRPC, GraphQL, WebSocket transport boundaries | application/domain/shared contracts |
|
||||||
|
| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered |
|
||||||
|
| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge |
|
||||||
|
| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only |
|
||||||
|
| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency |
|
||||||
|
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
|
||||||
|
|
||||||
|
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
|
||||||
|
Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` from
|
||||||
|
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
|
||||||
|
|
||||||
|
## Layer workflow
|
||||||
|
|
||||||
|
For a full use case, work in this order:
|
||||||
|
|
||||||
|
```text
|
||||||
|
domain-core
|
||||||
|
-> application-core
|
||||||
|
-> adapter:outbound:* (or persistence/identifier)
|
||||||
|
-> adapter:inbound:*
|
||||||
|
-> app-bootstrap wiring
|
||||||
|
```
|
||||||
|
|
||||||
|
Layer-only work stays inside that registered leaf plus its tests. If a required fix crosses a layer
|
||||||
|
or writable scope, stop and request context rather than expanding silently.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `domain-core`: pure JUnit unit tests.
|
||||||
|
- `application-core`: use-case tests with hand-rolled fakes; no web or persistence context.
|
||||||
|
- inbound adapters: focused transport slice/contract tests.
|
||||||
|
- persistence adapters: mapping/port contract tests; use a real datastore only when vendor semantics
|
||||||
|
require it.
|
||||||
|
- other outbound adapters: port contract tests with fake external systems; no real network.
|
||||||
|
- identifier: pure deterministic unit tests.
|
||||||
|
- bootstrap/settings: binding, validation, wiring, and architecture tests.
|
||||||
|
|
||||||
|
From `src/`, read the owning leaf's `gradle_path` from
|
||||||
|
`config/architecture/modules.json` and run `./gradlew <gradle-path>:test --console=plain`.
|
||||||
|
Architecture-wide commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
./gradlew verifyPublicPathSnapshot --console=plain
|
||||||
|
./gradlew verifyEnvKeys --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Use public-path and env-key checks only when their surfaces changed. Full `test` or `check` requires
|
||||||
|
the controller's workflow authorization.
|
||||||
|
|
||||||
|
## Advisory and reporting
|
||||||
|
|
||||||
|
Use dependency-DAG/topological reasoning and 3–5 materially distinct alternatives when that many
|
||||||
|
exist; use fewer when the option set is smaller. Judgment findings include falsifiable assumptions
|
||||||
|
and counterarguments. Deterministic compile, dependency, secret, or failed-test findings use
|
||||||
|
Rule → Evidence → Fix without manufactured debate.
|
||||||
|
|
||||||
|
Citation verification is profile-based: none for `review-lite`, blocking citations for
|
||||||
|
`review-standard`, and all material citations for `audit-deep`/`regulated`. Durable reports are
|
||||||
|
triggered by high risk, at least 3 blocking findings, an architecture decision, explicit user request,
|
||||||
|
or the regulated profile. Otherwise a concise result is allowed.
|
||||||
|
|
||||||
|
## LLM Wiki capture
|
||||||
|
|
||||||
|
For non-trivial implementation or workflow changes, use the exact vault path and capture sequence in
|
||||||
|
`AGENTS.md`. If the controller explicitly excludes wiki writes for a dispatched task, report the
|
||||||
|
handoff instead of writing outside scope.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# ca-skeleton — Clean Architecture Spring Boot 템플릿
|
||||||
|
|
||||||
|
ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean Architecture 백엔드 템플릿입니다. fork해서 도메인·패키지·엔티티·유스케이스만 교체하면 새 서비스를 시작할 수 있고, 모듈 경계와 의존 방향은 그대로 유지합니다. 기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 작업 기록 게시판)에 격리합니다.
|
||||||
|
|
||||||
|
이 문서는 전체 구조와 첫 실행만 다룹니다. 모듈별 상세 규칙과 설계 근거는 각 모듈의 README와 `CLAUDE.md`가, 빌드·환경 변수 상세는 [src/README.md](src/README.md)가 소유합니다.
|
||||||
|
|
||||||
|
## 아키텍처 한눈에
|
||||||
|
|
||||||
|
의존은 항상 바깥에서 안으로 흐릅니다. adapter가 core의 port에 의존하고, core는 adapter를 알지 못합니다. 이 방향이 유지되는 한 도메인 규칙과 기술 선택(웹 프레임워크, DB, 메시징)을 서로 독립적으로 바꿀 수 있습니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
app-bootstrap -> adapter:inbound:* -> application-core -> domain-core
|
||||||
|
app-bootstrap -> adapter:outbound:* -> application-core -> domain-core
|
||||||
|
모든 런타임 모듈 -> shared-contract
|
||||||
|
sample-portfolio -> 등록된 런타임 리프 (fixture 소비자 전용)
|
||||||
|
```
|
||||||
|
|
||||||
|
family 수준의 책임은 다음과 같습니다.
|
||||||
|
|
||||||
|
| 모듈 family | 책임 |
|
||||||
|
| --- | --- |
|
||||||
|
| `domain-core` | 순수 도메인 모델·불변식·이벤트·port. 프레임워크·transport·DB·IO 타입 금지 |
|
||||||
|
| `application-core` | command·유스케이스·application 정책·트랜잭션 port. inbound DTO·persistence entity 금지 |
|
||||||
|
| `adapter:inbound:*` | HTTP·gRPC·GraphQL·WebSocket transport 경계, DTO·validation·인증·에러 매핑 |
|
||||||
|
| `adapter:outbound:persistence-*` | JPA/PostgreSQL·MongoDB 영속 구현과 매핑·migration |
|
||||||
|
| `adapter:outbound:*` | support(공유 베이스)·messaging·cache·notification·object storage·file·HTTP client·identifier 능력을 port 뒤에서 구현. 외부 연동 어댑터(messaging·cache·notification·HTTP client)는 기본 비활성 |
|
||||||
|
| `shared-contract` | skeleton 전역 운영 계약. business/domain 개념 저장 금지 |
|
||||||
|
| `app-bootstrap` | Spring Boot entrypoint와 composition root |
|
||||||
|
| `sample-portfolio` | WorkLog 예시 도메인(fixture/reference). production이 의존하지 않음 |
|
||||||
|
|
||||||
|
정확한 19개 leaf 목록과 각 leaf의 Gradle path·소스 경로·허용 production 의존 edge는
|
||||||
|
[src/config/architecture/modules.json](src/config/architecture/modules.json)이 SSOT입니다. focused
|
||||||
|
test는 해당 Gradle path에서 `./gradlew <gradle-path>:test --console=plain` 형태로 파생하며, root
|
||||||
|
문서나 기억에서 개별 leaf edge를 추론하지 않습니다.
|
||||||
|
|
||||||
|
## 퀵스타트
|
||||||
|
|
||||||
|
전제조건은 Temurin 21(루트 [.tool-versions](.tool-versions)에 고정)과 Docker Engine 또는 Docker Desktop입니다. Gradle은 저장소 wrapper를 씁니다. 첫 실행 진입점은 하나입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
`bootstrap`은 compile 검사, PostgreSQL Compose 기동, 애플리케이션 이미지 build·기동(startup Flyway 포함), sample 격리 검증, `GET /api/healthcheck` HTTP smoke를 순서대로 실행합니다. 각 단계가 별도 Gradle task라 실패 단계가 task 이름으로 드러납니다. 기동을 확인하려면 health endpoint를 호출합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS http://localhost:8080/api/healthcheck
|
||||||
|
```
|
||||||
|
|
||||||
|
로컬 스택을 내릴 때는 저장소 루트에서 실행합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.local.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/.env`는 커밋된 안전 기본값이라 별도 `.env.example`을 만들지 않습니다. 전체 환경 변수 목록과 조정 시점은 [src/README.md](src/README.md)와 [docs/registries/env-keys.yaml](docs/registries/env-keys.yaml)에 있습니다.
|
||||||
|
|
||||||
|
### 프로파일별 데이터스토어
|
||||||
|
|
||||||
|
`bootstrap`은 컨테이너 경로(PostgreSQL)를 검증하는 첫 실행 진입점입니다. 일상 개발은 Docker 없이 돌리는 `local` 프로파일이며, 이때 데이터스토어는 H2 in-memory입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :app-bootstrap:bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
| 프로파일 | 데이터스토어 | 스키마 소유자 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `local` (bootRun 기본) | H2 in-memory | Hibernate `create-drop` |
|
||||||
|
| `dev` | PostgreSQL | Flyway |
|
||||||
|
| `prod` | PostgreSQL | Flyway |
|
||||||
|
|
||||||
|
`local`은 wiring과 애플리케이션 동작을 검증하고, migration과 vendor 동작은 검증하지 않습니다. 프로파일별 설정은 [src/app-bootstrap/src/main/resources/](src/app-bootstrap/src/main/resources/)의 `application-{local,dev,prod}.yml`이, 상세 설명은 [src/README.md](src/README.md)가 소유합니다.
|
||||||
|
|
||||||
|
## 새 프로젝트로 시작하기
|
||||||
|
|
||||||
|
이 저장소를 새 서비스의 출발점으로 쓸 때 핵심 단계는 다음과 같습니다. 전체 체크리스트는 [AGENTS.md](AGENTS.md)의 "템플릿 재사용 체크리스트"에 있습니다.
|
||||||
|
|
||||||
|
1. [src/settings.gradle](src/settings.gradle)의 `rootProject.name`을 새 서비스 이름으로 바꿉니다.
|
||||||
|
2. 패키지 루트 `dev.caskeleton`을 조직·서비스 패키지로 바꿉니다. 소스뿐 아니라 빌드·설정 파일의 참조도 함께 바꿔야 `mainClass`·`group`이 어긋나 `bootstrap`이 깨지지 않습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
find . -type f \( -name '*.java' -o -name '*.gradle' -o -name '*.yml' \) -print0 | xargs -0 sed -i 's/dev.caskeleton/com.yourorg.yourservice/g'
|
||||||
|
```
|
||||||
|
|
||||||
|
애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다.
|
||||||
|
|
||||||
|
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
|
||||||
|
4. 모듈 이름과 경계는 그대로 유지합니다.
|
||||||
|
|
||||||
|
검증은 sample-on과 sample-off를 모두 통과시킵니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew test
|
||||||
|
./gradlew :app-bootstrap:sampleOffTest
|
||||||
|
```
|
||||||
|
|
||||||
|
`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다.
|
||||||
|
|
||||||
|
## 아키텍처 규칙과 검증
|
||||||
|
|
||||||
|
애플리케이션이 동작하더라도 아래를 어기면 병합하지 않습니다. 8개 HARD-STOP 조건의 정본 로컬
|
||||||
|
정책 권위는 [AGENTS.md](AGENTS.md)이며, [CLAUDE.md](CLAUDE.md)는 동기화된 요약입니다.
|
||||||
|
|
||||||
|
- `domain-core`는 Spring·JPA·Servlet·HTTP·DB·cloud SDK 타입을 import하지 않습니다.
|
||||||
|
- controller는 repository를 직접 호출하거나 persistence entity를 반환하지 않습니다.
|
||||||
|
- inbound DTO는 `application-core`나 `domain-core`로 들어가지 않습니다.
|
||||||
|
- 비즈니스 정책은 mapper·filter·config·settings·controller에 두지 않습니다.
|
||||||
|
- 새 외부 시스템 연동은 domain/application port와 adapter 모듈로 표현합니다.
|
||||||
|
|
||||||
|
이 규칙은 두 축으로 자동 강제합니다. ArchUnit `CleanArchitectureTest`가 컴파일된 소스 의존성을,
|
||||||
|
`verifyCleanArchitectureDependencies` 게이트가 JSON registry의 허용 Gradle project edge를
|
||||||
|
검사합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew verifyCleanArchitectureDependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
두 검증 축은 [ci-quality-gates.yml](.github/workflows/ci-quality-gates.yml)의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다.
|
||||||
|
|
||||||
|
## 더 알아보기
|
||||||
|
|
||||||
|
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
|
||||||
|
- 모듈 레지스트리(19개 leaf SSOT): [src/config/architecture/modules.json](src/config/architecture/modules.json)
|
||||||
|
- 에이전트·기여자 작업 규칙: [AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md)
|
||||||
|
- 빌드·릴리스 공급망 파이프라인은 현재 Mode B 복구 범위에 포함되지 않았다. 현재 저장소가
|
||||||
|
제공하는 canonical workflow는 품질·의존성 취약점·링크 검사이며, release/publish 자동화는 별도
|
||||||
|
설계와 권한 검토 후 추가한다.
|
||||||
|
- 모듈별 설계 결정: [domain-core](src/domain-core/README.md) · [application-core](src/application-core/README.md) · [adapter:inbound:web](src/adapter/inbound/web/README.md) · [adapter:outbound:persistence-jpa](src/adapter/outbound/persistence-jpa/README.md) · [shared-contract](src/shared-contract/README.md) · [app-bootstrap](src/app-bootstrap/README.md)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# feature-container-runtime-contract — dev environment override
|
||||||
|
#
|
||||||
|
# Merge with base:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||||
|
#
|
||||||
|
# Dev intent:
|
||||||
|
# - Relaxes read-only filesystem (writable, no tmpfs constraint) so developers
|
||||||
|
# can hot-swap files without image rebuilds.
|
||||||
|
# - Increases memory allowance for dev/debugging workloads.
|
||||||
|
# - Disables restart-unless-stopped so crash loops don't mask startup errors.
|
||||||
|
# - Enables JMX remote port (local only, never in prod).
|
||||||
|
# - Mounts a local source volume for fast iteration (optional; mount when IDE
|
||||||
|
# attaches to the running container).
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
# Relax read-only constraint for local development.
|
||||||
|
read_only: false
|
||||||
|
tmpfs: [] # no tmpfs in dev; rely on normal writable rootfs
|
||||||
|
# More memory for dev profiling / heap dumps.
|
||||||
|
mem_limit: 1g
|
||||||
|
memswap_limit: 1g
|
||||||
|
environment:
|
||||||
|
TZ: "UTC"
|
||||||
|
LANG: "C.UTF-8"
|
||||||
|
LC_ALL: "C.UTF-8"
|
||||||
|
# Use a shorter drain timeout in dev so restarts are faster.
|
||||||
|
APP_SERVER_SHUTDOWN_TIMEOUT: "5s"
|
||||||
|
APP_SERVER_SHUTDOWN: "graceful"
|
||||||
|
# Remote JMX — bind to localhost only; NEVER expose in production.
|
||||||
|
JAVA_TOOL_OPTIONS: >-
|
||||||
|
-XX:MaxRAMPercentage=75
|
||||||
|
-XX:+UseContainerSupport
|
||||||
|
-XX:+ExitOnOutOfMemoryError
|
||||||
|
-XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
-XX:HeapDumpPath=/var/tmp/heap
|
||||||
|
-Dserver.tomcat.basedir=/tmp
|
||||||
|
-Dcom.sun.management.jmxremote
|
||||||
|
-Dcom.sun.management.jmxremote.port=9999
|
||||||
|
-Dcom.sun.management.jmxremote.rmi.port=9999
|
||||||
|
-Dcom.sun.management.jmxremote.authenticate=false
|
||||||
|
-Dcom.sun.management.jmxremote.ssl=false
|
||||||
|
-Djava.rmi.server.hostname=127.0.0.1
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "9001:9001"
|
||||||
|
- "127.0.0.1:9999:9999" # JMX — localhost only
|
||||||
|
# No health check constraint in dev; let the app start at its own pace.
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
# Do not restart automatically so crash loops stay visible.
|
||||||
|
restart: "no"
|
||||||
|
# Optional: mount heap dump directory to host for dev analysis.
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./tmp/heap-dumps
|
||||||
|
target: /var/tmp/heap
|
||||||
|
bind:
|
||||||
|
create_host_path: true
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# feature-container-runtime-contract — local infrastructure override
|
||||||
|
#
|
||||||
|
# Merge with base (and optionally dev):
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.local.yml up
|
||||||
|
#
|
||||||
|
# Local intent:
|
||||||
|
# - Starts a local PostgreSQL database for integration testing without Testcontainers.
|
||||||
|
# - Wires the app environment to point at the local DB.
|
||||||
|
# - Keeps read-only filesystem and memory limits from the base compose.
|
||||||
|
# - Publishes the DB on the loopback interface only, so a host-side run
|
||||||
|
# (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the
|
||||||
|
# containerised app reaches over the internal `caskeleton-local` network.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
env_file:
|
||||||
|
- ./src/.env
|
||||||
|
# Wire the app to the local Postgres service on the internal network.
|
||||||
|
environment:
|
||||||
|
TZ: "UTC"
|
||||||
|
LANG: "C.UTF-8"
|
||||||
|
LC_ALL: "C.UTF-8"
|
||||||
|
# fallback matches env-keys.yaml SSOT default (30s); base compose grace is 40s.
|
||||||
|
APP_SERVER_SHUTDOWN_TIMEOUT: "${APP_SERVER_SHUTDOWN_TIMEOUT:-30s}"
|
||||||
|
APP_SERVER_SHUTDOWN: "graceful"
|
||||||
|
# Database connection — points to the local `db` service below.
|
||||||
|
# Override with your actual DB credentials in a local .env file.
|
||||||
|
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
|
||||||
|
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||||
|
APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- "CMD"
|
||||||
|
- "wget"
|
||||||
|
- "--no-verbose"
|
||||||
|
- "--tries=1"
|
||||||
|
- "--spider"
|
||||||
|
- "http://localhost:8080/api/healthcheck"
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 20s
|
||||||
|
retries: 12
|
||||||
|
networks:
|
||||||
|
- caskeleton-local
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
|
||||||
|
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||||
|
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||||
|
TZ: "UTC"
|
||||||
|
# Persist data between restarts; remove the volume to start fresh.
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: caskeleton-db-data
|
||||||
|
target: /var/lib/postgresql/data
|
||||||
|
# The containerised app reaches this over the internal network and needs no host port. A
|
||||||
|
# host-side run does: src/.env is the dotenv source bootRun reads, and its committed
|
||||||
|
# APP_DATASOURCE_URL is jdbc:postgresql://localhost:5433/ca_skeleton. With the port unpublished
|
||||||
|
# that default named an address nothing in the repository provisioned, so every bootRun died in
|
||||||
|
# the startup migration phase with a connection refusal.
|
||||||
|
#
|
||||||
|
# Bound to 127.0.0.1, never 0.0.0.0: the database is reachable from this machine and from
|
||||||
|
# nowhere else on the network. Host 5433 (not 5432) so a PostgreSQL already installed on the
|
||||||
|
# host keeps its conventional port.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5433:5432"
|
||||||
|
networks:
|
||||||
|
- caskeleton-local
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caskeleton-local:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caskeleton-db-data:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# feature-container-runtime-contract — base docker-compose service definition
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose up # base only
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up # dev override
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.local.yml up # local override
|
||||||
|
#
|
||||||
|
# Graceful-shutdown sync table (feature-container-runtime-contract D5):
|
||||||
|
# App shutdown timeout : 30s (APP_SERVER_SHUTDOWN_TIMEOUT — SSOT: docs/registries/
|
||||||
|
# env-keys.yaml, owner feature-env-driven-runtime-configuration)
|
||||||
|
# Container preStop : 5s (handled by the orchestrator / stop_grace_period offset)
|
||||||
|
# stop_grace_period : 40s (30s app drain + 5s preStop + 5s safety margin)
|
||||||
|
#
|
||||||
|
# Ownership: env-driven-config owns the app shutdown *value* (30s); this contract owns the
|
||||||
|
# *relationship* (stop_grace_period >= app timeout + preStop + margin). The grace is therefore
|
||||||
|
# sized UP to the env-keys SSOT value (30s) — you do not shrink the app drain window to fit an
|
||||||
|
# arbitrary grace. This resolves the earlier 20s/35s drift, which had assumed an app-timeout
|
||||||
|
# value this branch does not own (env-keys.yaml is the SSOT and sets 30s).
|
||||||
|
#
|
||||||
|
# Memory limit (D4):
|
||||||
|
# The container memory limit MUST be set for -XX:MaxRAMPercentage=75 to be meaningful.
|
||||||
|
# Default 512m suits a small service; adjust per deployment.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: src/
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
RELEASE_VERSION: "${RELEASE_VERSION:-0.0.1}"
|
||||||
|
BUILD_VERSION: "${BUILD_VERSION:-0.0.1+0000000}"
|
||||||
|
GIT_SHA: "${GIT_SHA:-0000000}"
|
||||||
|
SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}"
|
||||||
|
image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000}
|
||||||
|
ports:
|
||||||
|
- "${APP_SERVER_PORT:-8080}:8080"
|
||||||
|
- "9001:9001"
|
||||||
|
environment:
|
||||||
|
TZ: "UTC"
|
||||||
|
LANG: "C.UTF-8"
|
||||||
|
LC_ALL: "C.UTF-8"
|
||||||
|
# Graceful shutdown — fallback matches the env-keys.yaml SSOT default (30s).
|
||||||
|
# Override via .env or docker compose --env-file if you need a different value.
|
||||||
|
APP_SERVER_SHUTDOWN_TIMEOUT: "${APP_SERVER_SHUTDOWN_TIMEOUT:-30s}"
|
||||||
|
APP_SERVER_SHUTDOWN: "graceful"
|
||||||
|
# ---- Read-only root filesystem (D2) -------------------------------------
|
||||||
|
# The application must write only to the tmpfs mounts declared below.
|
||||||
|
# /tmp — Tomcat basedir (see -Dserver.tomcat.basedir=/tmp in Dockerfile).
|
||||||
|
# /var/tmp/heap — heap dump landing zone (see -XX:HeapDumpPath=/var/tmp/heap).
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:mode=1777,size=128m
|
||||||
|
- /var/tmp/heap:mode=1777,size=512m
|
||||||
|
# ---- Fileserver storage volume ------------------------------------------
|
||||||
|
# A named volume, not a tmpfs and not the read-only root. The Fileserver platform's default
|
||||||
|
# storage root is /var/lib/backend/files, and with a read-only root and no mount there was
|
||||||
|
# nowhere on the image it could legally write: enabling the capability failed on its first
|
||||||
|
# upload rather than at startup. The volume is declared unconditionally because a volume
|
||||||
|
# nobody writes to costs nothing, while a missing one costs an outage.
|
||||||
|
#
|
||||||
|
# Ownership: the image runs as uid/gid 1000 (see src/Dockerfile). Docker initialises a fresh
|
||||||
|
# named volume from the image path's ownership, so the directory is created in the image with
|
||||||
|
# that owner; a pre-existing volume or a host bind mount must be chowned to 1000:1000 by the
|
||||||
|
# operator, or every write is refused with a permission error the application reports as
|
||||||
|
# STORAGE_UNAVAILABLE.
|
||||||
|
volumes:
|
||||||
|
- fileserver-data:/var/lib/backend/files
|
||||||
|
# ---- Memory limit (D4) --------------------------------------------------
|
||||||
|
# Must be set so -XX:MaxRAMPercentage=75 can compute a meaningful heap bound.
|
||||||
|
mem_limit: 512m
|
||||||
|
memswap_limit: 512m
|
||||||
|
# ---- Graceful shutdown (D5) ---------------------------------------------
|
||||||
|
# stop_grace_period = app drain (30s, env-keys SSOT) + preStop (5s) + margin (5s) = 40s.
|
||||||
|
stop_grace_period: 40s
|
||||||
|
# ---- Health check -------------------------------------------------------
|
||||||
|
# Targets the actuator readiness probe on the management port (9001).
|
||||||
|
# CROSS-FEATURE COUPLING: /actuator/health/readiness is implemented by the
|
||||||
|
# parallel runtime-health + actuator branches. This healthcheck will report
|
||||||
|
# UNHEALTHY in this worktree until those branches are merged.
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- "CMD"
|
||||||
|
- "wget"
|
||||||
|
- "--no-verbose"
|
||||||
|
- "--tries=1"
|
||||||
|
- "--spider"
|
||||||
|
- "http://localhost:9001/actuator/health/readiness"
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 60s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Survives container replacement, which is the point: published content outlives the process
|
||||||
|
# that wrote it. Back this with real storage in any deployment that keeps files.
|
||||||
|
fileserver-data:
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Object Storage Batch A Checkpoint
|
||||||
|
|
||||||
|
- Date: 2026-07-28
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Worktree:
|
||||||
|
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||||
|
- Claimed level: R0 application contract only
|
||||||
|
- Provider readiness advanced: no
|
||||||
|
|
||||||
|
## Implemented scope
|
||||||
|
|
||||||
|
- Characterized the legacy caller-key overwrite, whole-object materialization, locator exposure,
|
||||||
|
eager filesystem directory creation, optional S3 bucket provisioning, and Poster transaction/API
|
||||||
|
coupling without changing those behaviors.
|
||||||
|
- Added provider-neutral identities, opaque checked references/handles, bounded streaming
|
||||||
|
callbacks, content identity, digest/range values, requests, receipts, outcomes, and narrow ports
|
||||||
|
under `dev.caskeleton.application.objectstorage`.
|
||||||
|
- Required an `ObjectOperationKey` on mutation requests and separated normal publication,
|
||||||
|
scan-maintenance, purge-maintenance, direct, and staged privilege surfaces.
|
||||||
|
- Added recursive contract-purity tests and an ArchUnit freeze for the one existing sample legacy
|
||||||
|
import.
|
||||||
|
- Marked the legacy `ObjectStoragePort` and `StoredObject` as removal boundaries without adapting
|
||||||
|
new semantic calls back to raw keys.
|
||||||
|
|
||||||
|
No provider-neutral kernel, canonical namespace/control codec, local R1 provider, S3/MinIO
|
||||||
|
qualification, sample migration, or R2 readiness claim is included.
|
||||||
|
|
||||||
|
## TDD evidence
|
||||||
|
|
||||||
|
The planned RED checks failed only for the intentionally missing types or removal annotations:
|
||||||
|
|
||||||
|
- `ObjectStorageIdentityContractTest`: missing identity types before Task 2 implementation.
|
||||||
|
- `ObjectContentContractTest` and `ObjectStorageValueContractTest`: missing content/value types
|
||||||
|
before Task 3 implementation.
|
||||||
|
- `ObjectStoragePortContractTest`: missing request/receipt/port family before Task 4 implementation.
|
||||||
|
- `ObjectStorageArchitectureContractTest`: missing legacy removal annotations before Task 5
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
An initial ArchUnit DSL compilation error was a test-authoring error, not accepted as a RED result;
|
||||||
|
the rule was corrected and rerun.
|
||||||
|
|
||||||
|
## GREEN verification
|
||||||
|
|
||||||
|
All commands ran from `src/` and completed with `BUILD SUCCESSFUL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :application-core:resolveAndLockAll --write-locks
|
||||||
|
./gradlew :application-core:verifyDependencyLocks --console=plain
|
||||||
|
./gradlew :application-core:test --tests '*ObjectStorageIdentityContractTest' --console=plain
|
||||||
|
./gradlew :application-core:test \
|
||||||
|
--tests '*ObjectContentContractTest' \
|
||||||
|
--tests '*ObjectStorageValueContractTest' --console=plain
|
||||||
|
./gradlew :application-core:test --tests '*ObjectStoragePortContractTest' --console=plain
|
||||||
|
./gradlew :application-core:test \
|
||||||
|
--tests '*ObjectStorageArchitectureContractTest' --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
./gradlew :application-core:check --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test :sample-portfolio:test --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
The final combined legacy focused suites completed in 27 seconds. Deprecation-for-removal warnings
|
||||||
|
are expected evidence that legacy consumers remain visible; they are not suppressed.
|
||||||
|
|
||||||
|
## LLM Wiki capture
|
||||||
|
|
||||||
|
The canonical vault required by repository policy,
|
||||||
|
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`, and its parent
|
||||||
|
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. Therefore the required
|
||||||
|
`raw/branch-notes/codex-objectstorage-production-capability.md` could not be created or updated.
|
||||||
|
No similarly named non-canonical clone was used. This exact access block is recorded in both the
|
||||||
|
plan and design headers and here at the Batch A boundary.
|
||||||
|
|
||||||
|
## Remaining gates and risks
|
||||||
|
|
||||||
|
- External broker and REST consumers and deployed legacy data were not inspected; Gate A remains
|
||||||
|
blocked for legacy removal or public API versioning.
|
||||||
|
- The new contracts have no provider implementation yet.
|
||||||
|
- The current legacy adapter retains whole-object and raw-locator behavior by design until the
|
||||||
|
later migration batch.
|
||||||
|
- No readiness registry row is promoted by this checkpoint.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Object Storage Batch B Checkpoint
|
||||||
|
|
||||||
|
- Date: 2026-07-28
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Worktree:
|
||||||
|
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||||
|
- Evidence grade: repository-local non-skipping unit/contract/application-context tests
|
||||||
|
- Advanced cards: local managed single upload R1, local managed download R1
|
||||||
|
- R2 or production-provider readiness advanced: no
|
||||||
|
|
||||||
|
## Implemented scope
|
||||||
|
|
||||||
|
- Added deterministic data/control namespaces, opaque reference/handle codecs, canonical request
|
||||||
|
fingerprints, frozen binding/policy revisions, and bounded operation epochs.
|
||||||
|
- Added six strict canonical JSON control-record families with fixed field order, outer SHA-256
|
||||||
|
envelopes, schema/size checks, corruption rejection, and checked-in golden digests.
|
||||||
|
- Added provider-neutral publication, scan, reference, direct-session, multipart, and pending-effect
|
||||||
|
state transitions with same-operation replay and conflicting-intent rejection.
|
||||||
|
- Added a provider contract and `filesystem-local-dev` implementation with bounded streaming,
|
||||||
|
immutable exclusive create, SHA-256 verification, exact inspect/version, full/range transfer,
|
||||||
|
conditional retirement, create resolution, restrictive permissions, and path/symlink
|
||||||
|
confinement.
|
||||||
|
- Added single-process exact-version control CAS and restart/corruption/fault characterization.
|
||||||
|
Logical control keys use `.record` physical leaves locally so object-store-valid prefix/leaf key
|
||||||
|
pairs cannot collide as filesystem file/directory paths.
|
||||||
|
- Added constructor-bound `app.object-storage` settings and compile-before-construction
|
||||||
|
provider/destination/route/policy binding. The capability is disabled by default and
|
||||||
|
`filesystem-local-dev` is rejected for `prod`/`production`.
|
||||||
|
- Added disabled, unselected, invalid, selected-success, selected-construction-failure, close,
|
||||||
|
legacy-only, and namespace-separated dual-run composition tests.
|
||||||
|
- Added semantic routing evidence for publish, replay without producer invocation, inspect,
|
||||||
|
full transfer, absent reference, and exact retained route lookup.
|
||||||
|
- Added the exact nine-card readiness registry. Only local managed single upload/download are R1;
|
||||||
|
direct, multipart, quarantine, retention, and production reconciliation remain R0.
|
||||||
|
|
||||||
|
## TDD and defect evidence
|
||||||
|
|
||||||
|
Planned RED checks failed for the intentionally absent codec/kernel/provider/settings/readiness
|
||||||
|
types before each implementation. Additional tests found and drove these corrections:
|
||||||
|
|
||||||
|
- Local control keys may legally have both a leaf and a child in object storage, while a filesystem
|
||||||
|
cannot have both `reference` and `reference/lifecycle`; local physical `.record` mapping fixed the
|
||||||
|
collision without changing logical keys.
|
||||||
|
- `ObjectInspectionPort.inspect` initially threw for an absent known-route reference; it now
|
||||||
|
returns `Optional.empty()` while incomplete/corrupt evidence still fails closed.
|
||||||
|
- The application purity test initially scanned its own test output after a full `check`; it now
|
||||||
|
derives the production class root from a production contract type.
|
||||||
|
- The general B7 ArchUnit rule initially classified objectstorage provider-internal SPI/control
|
||||||
|
return values as public adapter responses. The existing negative fixture remains active, while a
|
||||||
|
dedicated non-empty rule now checks the actual objectstorage `*Adapter` semantic boundaries.
|
||||||
|
|
||||||
|
No skipped Docker or external-service test is used as Batch B readiness evidence.
|
||||||
|
|
||||||
|
## GREEN verification
|
||||||
|
|
||||||
|
All commands ran from `src/` unless noted and completed with `BUILD SUCCESSFUL` after the documented
|
||||||
|
RED/fix cycles:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*ObjectNamespaceCodecTest' \
|
||||||
|
--tests '*ObjectRequestFingerprintCodecTest' \
|
||||||
|
--tests '*ObjectOperationEpochTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*ObjectControlRecordCodecTest' \
|
||||||
|
--tests '*ObjectOperationStateMachineTest' \
|
||||||
|
--tests '*ObjectOperationKernelTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*ObjectStorageProviderContract' \
|
||||||
|
--tests '*LocalDevObjectStorageProviderTest' \
|
||||||
|
--tests '*LocalDevObjectStorageRecoveryTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*ObjectStorageBindingCompilerTest' \
|
||||||
|
--tests '*ObjectStorageCapabilityConfigTest' \
|
||||||
|
--tests '*RoutingObjectStorageAdapterTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*ObjectStorageReadinessRegistryTest' --console=plain
|
||||||
|
./gradlew :sample-portfolio:test --console=plain
|
||||||
|
./gradlew :application-core:check \
|
||||||
|
:adapter:outbound:objectstorage:check --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
The final combined application/objectstorage checkpoint completed in 23 seconds. The focused
|
||||||
|
Clean Architecture suite and dependency verification also passed.
|
||||||
|
|
||||||
|
## LLM Wiki capture
|
||||||
|
|
||||||
|
The canonical vault required by repository policy,
|
||||||
|
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`, and its parent
|
||||||
|
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. Therefore the required
|
||||||
|
`raw/branch-notes/codex-objectstorage-production-capability.md` and any derived raw documents could
|
||||||
|
not be created or updated. No similarly named non-canonical clone was used. This exact access block
|
||||||
|
is recorded in the plan/design status and at this Batch B boundary.
|
||||||
|
|
||||||
|
## Remaining gates and risks
|
||||||
|
|
||||||
|
- `filesystem-local-dev` has no multi-node linearizability or power-loss durability evidence and is
|
||||||
|
forbidden in production profiles.
|
||||||
|
- The canonical S3/MinIO provider contribution, async bounded transport, provider qualification,
|
||||||
|
response-loss fault tests, and protected AWS evidence are not implemented.
|
||||||
|
- Direct grants, multipart, quarantine/scan, retention/legal hold, privileged purge, reapers, and
|
||||||
|
production reconciliation remain R0.
|
||||||
|
- The sample Poster workflow still uses the deprecated whole-`byte[]` port and transaction-coupled
|
||||||
|
legacy choreography. It is explicitly activated only in sample local/test configuration.
|
||||||
|
- External API/broker consumers and deployed legacy data remain uninspected, so Gate A still blocks
|
||||||
|
destructive migration or legacy removal.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Object Storage Batch C Checkpoint
|
||||||
|
|
||||||
|
- Date: 2026-07-28
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Worktree:
|
||||||
|
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||||
|
- Evidence grade: repository-local tests plus digest-pinned single-node MinIO/Toxiproxy tests
|
||||||
|
- AWS execution: not authorized; source set compiled only
|
||||||
|
- Production-provider readiness advanced: no
|
||||||
|
|
||||||
|
## Implemented scope
|
||||||
|
|
||||||
|
- Added exact AWS S3 and MinIO provider bindings, bounded evidence descriptors, qualifier/error
|
||||||
|
mapping, secret references, endpoint/owner/addressing validation, and selected-only lifecycle
|
||||||
|
construction.
|
||||||
|
- Added bounded async request/response bridges and the managed S3 put, inspect, full/range download,
|
||||||
|
checksum, exact-version, cancellation, and content-length paths.
|
||||||
|
- Added canonical conditional S3 control storage and operation response-loss resolution. Provider
|
||||||
|
ETags remain adapter-private and are never exposed as logical versions.
|
||||||
|
- Added low-level managed multipart planning, sharded immutable part ledgers, initiate-before-I/O
|
||||||
|
state, explicit create/upload/list/complete/abort calls, and exact completion verification.
|
||||||
|
- Added non-skipping MinIO contract/fault lanes, an AWS compile-only qualification lane, a protected
|
||||||
|
workflow, and gate-matrix coverage.
|
||||||
|
|
||||||
|
The exact MinIO image is
|
||||||
|
`minio/minio@sha256:4c4a4876193f030c81f57aabb22bcb9a73462010eb61fcab66908e03e5484af8`.
|
||||||
|
The exact Toxiproxy image is
|
||||||
|
`ghcr.io/shopify/toxiproxy@sha256:9378ed52a28bc50edc1350f936f518f31fa95f0d15917d6eb40b8e376d1a214e`.
|
||||||
|
|
||||||
|
## Exact MinIO finding
|
||||||
|
|
||||||
|
Real-provider tests proved an asymmetric conditional profile:
|
||||||
|
|
||||||
|
- `PutObject If-None-Match: *` was accepted but overwrote an existing object.
|
||||||
|
- stale `PutObject If-Match` was rejected with HTTP 412.
|
||||||
|
- `CompleteMultipartUpload If-None-Match: *` was accepted and overwrote an existing object.
|
||||||
|
- checksum, HEAD, and range behavior passed the exercised contract.
|
||||||
|
|
||||||
|
Because immutable create and create-if-absent control CAS cannot be proven, the exact MinIO managed
|
||||||
|
and direct mutation profiles remain `UNSUPPORTED`. The implementation does not emulate missing
|
||||||
|
atomicity with HEAD followed by an unconditional write and does not promote a readiness card.
|
||||||
|
|
||||||
|
## TDD and verification
|
||||||
|
|
||||||
|
The task-focused RED runs first failed on the planned absent binding, bridge, conditional store,
|
||||||
|
multipart, and qualification types. Provider qualification then found the real MinIO conditional
|
||||||
|
behavior above; the descriptor and negative contract were changed instead of weakening the
|
||||||
|
contract.
|
||||||
|
|
||||||
|
Commands completed with `BUILD SUCCESSFUL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*S3ProviderBindingTest' \
|
||||||
|
--tests '*S3ProviderQualifierTest' \
|
||||||
|
--tests '*S3ProviderCompositionTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:check --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks \
|
||||||
|
verifyCleanArchitectureDependencies --console=plain
|
||||||
|
bash ../.github/scripts/verify-gate-matrix.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The gate matrix reports 22 gates: 21 verified and the protected AWS qualification gate explicitly
|
||||||
|
`delegated-pending`.
|
||||||
|
|
||||||
|
## LLM Wiki capture
|
||||||
|
|
||||||
|
The canonical vault `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` and its parent
|
||||||
|
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. The required
|
||||||
|
`raw/branch-notes/codex-objectstorage-production-capability.md` and derived raw documents could not
|
||||||
|
be created or updated. No similarly named non-canonical clone was used.
|
||||||
|
|
||||||
|
## Remaining risks
|
||||||
|
|
||||||
|
- No AWS request was executed, so there is no observed AWS provider claim.
|
||||||
|
- The pinned MinIO topology is a local single-node container and is not production TLS,
|
||||||
|
multi-node, durability, or linearizability evidence.
|
||||||
|
- The detailed managed multipart fault matrix is not exhaustive enough for R2.
|
||||||
|
- No sample migration, public API, scan/publication choreography, retention, purge, or reaper is
|
||||||
|
included in this checkpoint.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Object Storage Batch D Checkpoint
|
||||||
|
|
||||||
|
- Date: 2026-07-28
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Worktree:
|
||||||
|
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||||
|
- Scope: direct-transfer provider/application primitives only
|
||||||
|
- Public endpoint: none
|
||||||
|
- Readiness advanced: no; all direct cards remain R0
|
||||||
|
|
||||||
|
## Implemented scope
|
||||||
|
|
||||||
|
- Added direct single-upload session policy, durable prepared/issued transitions, bearer
|
||||||
|
redaction, exact completion verification, published-version download resolution, and an
|
||||||
|
S3-presigner lifecycle owned by the selected provider.
|
||||||
|
- Added direct multipart durable session and part-grant families, opaque acknowledgement tokens,
|
||||||
|
sharded part records, admission-close/expiry fencing, exact ledger validation, completion/abort
|
||||||
|
states, response-loss resolution, and persisted terminal exact-version replay.
|
||||||
|
- Added direct S3 initiate/discovery, exact-part presign, `ListParts` acknowledgement, conditional
|
||||||
|
complete followed by exact HEAD verification, and abort resolution.
|
||||||
|
- Registered direct single and multipart delegates only when their exact compiled capability is
|
||||||
|
selected. One presigner is constructed and closed exactly once.
|
||||||
|
- Added golden canonical envelopes for the direct session, direct multipart session, and direct
|
||||||
|
multipart grant families.
|
||||||
|
- Fixed `MultipartCompleteRequest` null validation so valid immutable `List.of(...)` input no longer
|
||||||
|
throws from `contains(null)`.
|
||||||
|
|
||||||
|
## Qualification truth
|
||||||
|
|
||||||
|
The exact MinIO release cannot prove create-only PUT or create-only multipart completion, so both
|
||||||
|
direct profiles are explicitly `UNSUPPORTED`. The direct MinIO contract/fault lanes are negative
|
||||||
|
admission tests: they prove no bearer or multipart mutation enters an unsupported profile. No test
|
||||||
|
skip is used as positive evidence.
|
||||||
|
|
||||||
|
The AWS managed/direct source sets compile, but no AWS call was made and no AWS evidence row was
|
||||||
|
published. No inbound controller, authorization surface, CORS runtime configuration, or public
|
||||||
|
direct API exists.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Commands completed with `BUILD SUCCESSFUL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*DirectTransferCoordinatorTest' \
|
||||||
|
--tests '*PresignedGrantRedactionTest' \
|
||||||
|
--tests '*S3DirectTransferProviderTest' \
|
||||||
|
--tests '*ObjectControlRecordCodecTest' \
|
||||||
|
--tests '*S3ProviderCompositionTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*DirectMultipartCoordinatorTest' \
|
||||||
|
--tests '*DirectMultipartRaceTest' \
|
||||||
|
--tests '*S3DirectMultipartProviderTest' \
|
||||||
|
--tests '*ObjectControlRecordCodecTest' \
|
||||||
|
--tests '*S3ProviderCompositionTest' --console=plain
|
||||||
|
./gradlew \
|
||||||
|
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
|
||||||
|
:adapter:outbound:objectstorage:objectStorageMinioFaultTest \
|
||||||
|
--tests '*DirectTransfer*' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*DirectTransferCorsContractTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:check --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks \
|
||||||
|
verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew \
|
||||||
|
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
|
||||||
|
:adapter:outbound:objectstorage:objectStorageMinioFaultTest --console=plain
|
||||||
|
bash ../.github/scripts/verify-gate-matrix.sh
|
||||||
|
./gradlew test --console=plain
|
||||||
|
./gradlew check --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
The module `check` includes unit tests, Checkstyle, Spotless, SpotBugs, architecture, configuration
|
||||||
|
processor, environment-key, and repository-wide policy checks. Existing test-only compiler
|
||||||
|
warnings remain non-failing. The final repository-wide test run completed 79 tasks and the final
|
||||||
|
repository-wide check completed 214 tasks.
|
||||||
|
|
||||||
|
## Deliberate limitations
|
||||||
|
|
||||||
|
- Issued bearer material is process-local. A restart fails closed instead of reconstructing or
|
||||||
|
reissuing an already-issued bearer.
|
||||||
|
- The signing clock/window is stored and bounded, but AWS SDK presigner query timing is not driven
|
||||||
|
by the injected application clock.
|
||||||
|
- The direct multipart recovery/race matrix covers its principal fences and completion response
|
||||||
|
loss but is not exhaustive enough for an R2 claim.
|
||||||
|
- Retention/Object Lock grant headers and a provider-enforced direct-single hard size ceiling are
|
||||||
|
not qualified.
|
||||||
|
- No public endpoint exists, so CORS evidence is a pure contract and no direct card may exceed R0
|
||||||
|
in the current registry.
|
||||||
|
|
||||||
|
## Approval Gate A
|
||||||
|
|
||||||
|
Tasks 20–24 remain blocked until the user explicitly approves scanner ownership, the sample's
|
||||||
|
first publication profile, the additive asynchronous API/status contract, and digest transport.
|
||||||
|
No scan/publication/sample endpoint implementation was started.
|
||||||
|
|
||||||
|
## LLM Wiki capture
|
||||||
|
|
||||||
|
The canonical vault `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` and its parent
|
||||||
|
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. The required
|
||||||
|
`raw/branch-notes/codex-objectstorage-production-capability.md` and derived raw documents could not
|
||||||
|
be created or updated. No similarly named non-canonical clone was used.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Object Storage Batch E Pause Checkpoint
|
||||||
|
|
||||||
|
- Recorded: 2026-07-29 (Asia/Seoul)
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Worktree:
|
||||||
|
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||||
|
- Status: implementation in progress; intentionally paused at the user's request
|
||||||
|
- Evidence grade: local unit/integration/architecture evidence only; no AWS R2 evidence
|
||||||
|
|
||||||
|
## Implemented at this checkpoint
|
||||||
|
|
||||||
|
- Staged integrity verification, fake-scanner routing, publication handoff fencing, and stable
|
||||||
|
replay receipts.
|
||||||
|
- Additive Poster V8 dual-read schema (renumbered from branch-local V7 during JPA integration),
|
||||||
|
upload/retirement intents, HMAC-sanitized idempotency scope,
|
||||||
|
PostgreSQL atomic claim SPI, and forward-only migration qualification lane.
|
||||||
|
- Short-transaction Poster image publication flow and additive locator-free `202` API under the
|
||||||
|
AIP-122-compatible `/posters/{id}/imagePublications` collection.
|
||||||
|
- Exact-reference/version logical retirement enqueue, lease/fence takeover, response-loss retry,
|
||||||
|
Poster deletion survival, and disabled-by-default worker composition.
|
||||||
|
- Isolated legacy migration contracts, report-only inspection, two-distinct-approver Ed25519
|
||||||
|
approval verification, nonce replay boundary, and explicit maintenance-only composition.
|
||||||
|
|
||||||
|
## Verification completed
|
||||||
|
|
||||||
|
The following focused command passed after the final architecture fixes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew \
|
||||||
|
:sample-portfolio:spotlessApply \
|
||||||
|
:sample-portfolio:test --tests '*PosterImagePublicationControllerWireTest' \
|
||||||
|
:app-bootstrap:test --tests '*CleanArchitectureTest' \
|
||||||
|
--console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
The following focused suites also passed during this checkpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*LegacyObjectAdoptionServiceTest' \
|
||||||
|
--tests '*LegacyAdoptionApprovalVerifierTest' \
|
||||||
|
--tests '*ObjectStorageLegacyMigrationConfigTest' --console=plain
|
||||||
|
|
||||||
|
./gradlew :sample-portfolio:test \
|
||||||
|
--tests '*DeletePosterImageRetirementTest' \
|
||||||
|
--tests '*PosterImageRetirementCrashMatrixTest' \
|
||||||
|
--tests '*PosterImageRetirementConfigTest' \
|
||||||
|
--tests '*LegacyPosterImageUploadCharacterizationTest' --console=plain
|
||||||
|
|
||||||
|
./gradlew :sample-portfolio:test \
|
||||||
|
--tests '*SampleApplicationContextTest' \
|
||||||
|
:sample-portfolio:posterImageMigrationTest --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration lane included
|
||||||
|
`PosterImageRetirementQualificationTest`, which proved that an exact retirement row survives
|
||||||
|
deletion of its Poster row.
|
||||||
|
|
||||||
|
## Failures found and resolved
|
||||||
|
|
||||||
|
- `spotlessJavaCheck` initially found formatting drift in newly changed application-core and
|
||||||
|
persistence files. The owner-module Spotless apply tasks fixed it.
|
||||||
|
- `SampleApplicationContextTest` initially failed because Spring's persistence exception advisor
|
||||||
|
could not CGLIB-proxy the final `PosterImageAttachmentCasRepository`. Removing `final` fixed the
|
||||||
|
context; the focused context suite then passed.
|
||||||
|
- `CleanArchitectureTest` initially rejected an application-core return type from the sample domain
|
||||||
|
and the kebab-case `image-publications` path. Conversion moved back to the application use case,
|
||||||
|
and the endpoint changed to the repository's AIP-122-compatible `imagePublications` segment. The
|
||||||
|
complete focused architecture suite then passed.
|
||||||
|
|
||||||
|
## Not yet re-run / not complete
|
||||||
|
|
||||||
|
- The combined Batch E checkpoint command stopped on the two architecture failures above before all
|
||||||
|
requested root tasks could complete. The focused failing suites passed after the fixes, but
|
||||||
|
`:sample-portfolio:check`, `verifyPublicPathSnapshot`, and the full combined Batch E command have
|
||||||
|
not been re-run after those final fixes.
|
||||||
|
- The complete repository `./gradlew test` and `./gradlew check` have not been re-run after the
|
||||||
|
Batch E additions.
|
||||||
|
- The legacy adoption runner/configuration is not yet wired to a production legacy inspector,
|
||||||
|
permission-checked trust-key loader, or durable control-record replay-store implementation.
|
||||||
|
- Tasks 25–30 (Batch F) have not started in this continuation.
|
||||||
|
- Actual AWS qualification is blocked by Approval Gate B: no approved account, bucket/namespaces,
|
||||||
|
workload roles, signed deployment attestation, or mutation/test authority was supplied.
|
||||||
|
- No readiness card was promoted. Local/MinIO ceilings and unsupported conditional behavior remain
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## Wiki capture
|
||||||
|
|
||||||
|
At this isolated-branch checkpoint, the then-selected private vault
|
||||||
|
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/` was absent, so capture was blocked. The final
|
||||||
|
main integration was later captured in the user-designated public vault at
|
||||||
|
`raw/branch-notes/chore-main-worktree-capability-integration.md`, with the derived error note
|
||||||
|
`raw/errors/multi-worktree-contract-drift-2026-07-31.md`.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Object Storage Phase 0 Inventory
|
||||||
|
|
||||||
|
- Captured: 2026-07-28
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Scope: repository-local source, tests, configuration, migrations, and documentation
|
||||||
|
- Evidence grade: repository-local only; deployed data, broker subscribers, and external REST
|
||||||
|
consumers were not inspected
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n 'application\.storage|ObjectStoragePort|StoredObject|ca-skeleton\.objectstorage|file://|s3://' \
|
||||||
|
src docs
|
||||||
|
rg -n 'image_key|posters/.*/image' src/sample-portfolio
|
||||||
|
rg -n 'poster\.image-attached|StoredObjectResponse|PosterResponse|imageKey' \
|
||||||
|
src/sample-portfolio docs
|
||||||
|
```
|
||||||
|
|
||||||
|
The commands completed successfully in the isolated worktree. Results are classified below.
|
||||||
|
Documentation hits in the Object Storage design/plan describe the migration and are not runtime
|
||||||
|
consumers. The `s3://bucket/key-1` fixture in
|
||||||
|
`IdempotencyStoreAdapterTest` belongs to the generic idempotency response-reference test and is not
|
||||||
|
an Object Storage legacy-port consumer.
|
||||||
|
|
||||||
|
## Repository-local runtime inventory
|
||||||
|
|
||||||
|
| Contract/data | Producer | Repository-local consumers | Classification |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `ObjectStoragePort` / `StoredObject` | `application-core/application/storage` | filesystem and S3 adapters, `UploadPosterImageUseCase`, `PosterController`/`PosterWebMapper` | legacy runtime contract |
|
||||||
|
| `ca-skeleton.objectstorage.*` | `ObjectStorageSettings` / `ObjectStorageConfig` | sample runtime through its objectstorage runtime dependency | legacy runtime configuration |
|
||||||
|
| `file://` receipt | `FilesystemObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator |
|
||||||
|
| `s3://bucket/key` receipt | `S3ObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator |
|
||||||
|
| `/posters/{id}/image` | `PosterController` | repository tests and the generated/public HTTP contract | legacy inbound API |
|
||||||
|
| `StoredObjectResponse` | `PosterController` / `PosterWebMapper` | HTTP caller, with `key`, `size`, `contentType`, and `location` | legacy response DTO |
|
||||||
|
| `PosterResponse.imageKey` | `PosterWebMapper` | list/get/create/update/publish/archive HTTP responses | legacy general response field |
|
||||||
|
| `poster.image-attached` | `PosterEventPublisher` | no subscriber found in this repository | versionless broker event; external consumers unknown |
|
||||||
|
| `poster.image-attached.imageKey` | `PosterImageAttached` and publisher JSON | no subscriber found in this repository | raw locator-shaped event field |
|
||||||
|
| `poster.image_key` | Flyway V6, `PosterEntity`, persistence mapper | `Poster` aggregate and repository adapter | stored-data schema |
|
||||||
|
| `posters/{id}/image` key | `UploadPosterImageUseCase` | aggregate `imageKey`, event payload, DB row, HTTP response | deterministic overwriteable legacy key |
|
||||||
|
|
||||||
|
## Executable characterization
|
||||||
|
|
||||||
|
The following tests pin the current behavior without approving it as the target design:
|
||||||
|
|
||||||
|
- `LegacyObjectStorageBehaviorTest`
|
||||||
|
- caller-selected keys overwrite;
|
||||||
|
- `get` returns `Optional<byte[]>` and materializes the whole object;
|
||||||
|
- receipts expose `file://` and `s3://` locators.
|
||||||
|
- `LegacyObjectStorageConfigTest`
|
||||||
|
- missing backend configuration selects filesystem;
|
||||||
|
- context creation creates the filesystem directory before the first write;
|
||||||
|
- `autoCreateBucket=true` probes and creates a missing bucket during S3 bean construction.
|
||||||
|
- `LegacyPosterImageUploadCharacterizationTest`
|
||||||
|
- remote storage is called while `TransactionPort.inWrite` is active;
|
||||||
|
- the controller calls `MultipartFile.getBytes`;
|
||||||
|
- the response exposes raw key and provider location;
|
||||||
|
- Poster deletion leaves the legacy object untouched.
|
||||||
|
|
||||||
|
## External inventory gap and Gate A
|
||||||
|
|
||||||
|
Repository search does not prove that the following have no deployed consumers:
|
||||||
|
|
||||||
|
- `POST /posters/{id}/image`;
|
||||||
|
- `StoredObjectResponse.key` and `.location`;
|
||||||
|
- `PosterResponse.imageKey`;
|
||||||
|
- broker event type `poster.image-attached` and its `imageKey` payload;
|
||||||
|
- rows already stored in `poster.image_key`;
|
||||||
|
- filesystem/S3 objects already written under `posters/{id}/image`.
|
||||||
|
|
||||||
|
No deployed database, object namespace, access log, API client catalog, broker consumer group, schema
|
||||||
|
registry, or owning team approval was inspected. Therefore removal, in-place field rename, event
|
||||||
|
payload replacement, or legacy-object deletion remains blocked. Approval Gate A must obtain owner
|
||||||
|
and consumer evidence and choose an additive/versioned migration contract.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Fileserver configuration
|
||||||
|
|
||||||
|
Every key below lives under `app.fileserver-platform` (environment form
|
||||||
|
`APP_FILESERVER_PLATFORM_*`). That namespace is the HTTP platform's alone: `app.fileserver.*`
|
||||||
|
belongs to the R2 tabular publication capability and `app.file-export.*` to the R1 CSV export, and
|
||||||
|
the three are deliberately separate so switching one on cannot switch on another.
|
||||||
|
|
||||||
|
While `app.fileserver-platform.enabled` is false none of these keys is bound at all — the
|
||||||
|
auto-configuration that binds them is not processed — so a malformed value in a block nobody
|
||||||
|
enabled cannot fail a startup. Once enabled, binding is strict: an unknown key under the prefix is
|
||||||
|
refused rather than ignored. The defaults are the conservative ones: the
|
||||||
|
capability is off, the admin plane is off, background reclamation is off, and there is no permissive
|
||||||
|
authorization fallback. Turning the capability on is a deliberate act, and so is every surface it
|
||||||
|
exposes.
|
||||||
|
|
||||||
|
## Minimum to start
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ca-skeleton:
|
||||||
|
fileserver:
|
||||||
|
enabled: true
|
||||||
|
instance-id: ${HOSTNAME} # writer-lease owner; must be unique per node
|
||||||
|
storage:
|
||||||
|
root: /var/lib/backend/files # absolute, outside any webroot or config dir
|
||||||
|
security:
|
||||||
|
access-policy: role-based # or supply your own FileAccessPolicy bean
|
||||||
|
observability:
|
||||||
|
fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
Startup fails, rather than degrading, when any of these is missing or unsafe:
|
||||||
|
|
||||||
|
| Condition | Why it is fatal |
|
||||||
|
| --- | --- |
|
||||||
|
| `security.access-policy` left at `required` with no `FileAccessPolicy` bean | a file capability that authorizes by default is worse than one that refuses to start |
|
||||||
|
| `observability.fingerprint-key` unset while metrics are on | an unkeyed digest of an enumerable identifier is reversible |
|
||||||
|
| the storage root fails a mandatory capability probe | a volume that cannot create atomically, keep staging and content on one FileStore, or refuse symlinks is unsafe, not degraded |
|
||||||
|
| `storage.publish-mode: atomic-move-required` on a volume where the probe could not prove an atomic move | the configured guarantee cannot be delivered |
|
||||||
|
| `security.access-policy: unenforced` under a `prod` profile | a value that was convenient in development must not survive promotion |
|
||||||
|
|
||||||
|
## Authorization — `security`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `access-policy` | `required` | `required` (supply your own bean), `role-based`, or `unenforced` |
|
||||||
|
| `read-roles` | `ROLE_FILE_READ` | grants metadata read and download |
|
||||||
|
| `write-roles` | `ROLE_FILE_WRITE` | grants create, append, finalize, delete, copy, move |
|
||||||
|
| `admin-roles` | `ROLE_FILE_ADMIN` | grants reverify and force-delete, and gates `/internal/fileserver/**` at the servlet chain |
|
||||||
|
|
||||||
|
There is no anonymous-read switch. Every Fileserver route is authenticated by the servlet chain
|
||||||
|
before any application policy is consulted, so such a setting could only ever have described a
|
||||||
|
permission the transport had already refused — a configuration that reads as if it grants access
|
||||||
|
and does not.
|
||||||
|
|
||||||
|
The three tiers do not inherit. An admin role cannot delete through the data plane, and a write role
|
||||||
|
cannot reach the management plane — a role model where "can delete" implied "can force-delete" would
|
||||||
|
make the audited plane reachable through the unaudited one.
|
||||||
|
|
||||||
|
`unenforced` authorizes everything and exists so a developer can exercise upload and download before
|
||||||
|
deciding on a role model. It is refused under a production profile.
|
||||||
|
|
||||||
|
## Storage — `storage`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `root` | `/var/lib/backend/files` | absolute path; the only place a path exists |
|
||||||
|
| `publish-mode` | `atomic-move-preferred` | `atomic-move-required`, `atomic-move-preferred`, `metadata-pointer` |
|
||||||
|
| `buffer-size` | `128KB` | bounds every transfer allocation; memory never scales with file size |
|
||||||
|
| `forbidden-root-ancestors` | `/app,/etc,/usr/share/nginx/html` | roots the storage root must not live under (webroot, config dirs) |
|
||||||
|
|
||||||
|
`root` must be absolute. A relative root resolves against the process working directory, which is
|
||||||
|
one path in a container and another in a test, so it is refused at binding time.
|
||||||
|
|
||||||
|
Three former keys are gone, pinned as constants instead: staging and content share one FileStore,
|
||||||
|
symbolic links are never followed, and the object and its directory are synced before READY. Each
|
||||||
|
is an invariant the atomic publish and the namespace boundary are built on — a deployment that
|
||||||
|
could switch one off would be running a different capability under the same name and the same
|
||||||
|
tests.
|
||||||
|
|
||||||
|
The storage provider has no selector either. There is exactly one implementation, and a `type` key
|
||||||
|
with one legal value is a promise of pluggability that nothing keeps.
|
||||||
|
|
||||||
|
## Upload, download, transfer
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `upload.max-file-size` | `100MB` | hard ceiling; also drives `spring.servlet.multipart.max-file-size` |
|
||||||
|
| `upload.max-request-size` | `110MB` | request envelope; must be at least `max-file-size` |
|
||||||
|
| `upload.initial-reservation` | `8MB` | quota reserved when the length is unknown |
|
||||||
|
| `upload.ttl` | `1h` | how long a resumable upload stays claimable |
|
||||||
|
| `upload.reservation-ttl` | `24h` | how long an unsettled quota reservation survives |
|
||||||
|
| `upload.lease-duration` | `30s` | writer lease; renewed at one third of this |
|
||||||
|
| `upload.max-parts` | `16` | multipart part ceiling |
|
||||||
|
| `upload.require-content-length` | `false` | refuse chunked raw uploads |
|
||||||
|
| `download.cache-control` | `private, no-store` | emitted on every content response |
|
||||||
|
| `download.inline-allowed` | `false` | scriptable content is always an attachment regardless |
|
||||||
|
| `download.max-ranges` | `1` | multi-range responses are opt-in |
|
||||||
|
| `download.max-range-bytes` | `100MB` | total bytes one ranged response may cover |
|
||||||
|
| `download.zero-copy-enabled` | `true` | hand large plaintext responses to the kernel |
|
||||||
|
| `download.zero-copy-minimum-bytes` | `16MB` | below this the syscall setup costs more than it saves |
|
||||||
|
| `transfer.core-size` / `max-size` / `queue-capacity` | `8` / `32` / `64` | blocking transfer pool bounds |
|
||||||
|
| `transfer.await-seconds` | `300` | how long a transfer may occupy a pool thread |
|
||||||
|
|
||||||
|
Zero copy changes no header and no status. When storage declines it — an unreadable region, an
|
||||||
|
unsupported backend — the response is streamed instead and is byte-identical.
|
||||||
|
|
||||||
|
## Verification — `verification`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `timeout` | `5s` | per-verifier ceiling |
|
||||||
|
| `require-media-type-verdict` | `false` | refuse a file whose type could not be determined |
|
||||||
|
| `inline-safe-profile` | `false` | accept scriptable content instead of quarantining it |
|
||||||
|
|
||||||
|
Set `inline-safe-profile: true` only when downloads are never served inline from a trusted origin.
|
||||||
|
|
||||||
|
## Quota and admission — `quota`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `instance-upload-permits` | `16` | concurrent uploads this node admits |
|
||||||
|
| `scope-upload-permits` | `4` | concurrent uploads one namespace admits |
|
||||||
|
| `direct-download-permits` | `64` | concurrent non-delegated downloads |
|
||||||
|
| `soft-high-water` | `0.70` | storage fraction at which pressure is reported |
|
||||||
|
| `hard-high-water` | `0.85` | storage fraction at which uploads are refused |
|
||||||
|
|
||||||
|
When the storage fraction cannot be read, admission treats it as unknown and does not apply the
|
||||||
|
high-water rule — a synthetic `0` would silently disable the guard, and a synthetic `1` would take
|
||||||
|
the capability down over a failed syscall.
|
||||||
|
|
||||||
|
## Background reclamation — `cleanup`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | run the cleanup worker on this node |
|
||||||
|
| `interval` | `60s` | fixed delay between batches, not fixed rate |
|
||||||
|
| `max-items` | `100` | items one batch may claim |
|
||||||
|
| `max-bytes` | `1GB` | bytes one batch may reclaim |
|
||||||
|
| `retry-backoff` | `5m` | how long a failed item waits before it is due again |
|
||||||
|
|
||||||
|
The worker deletes physical objects, so it is off until a deployment decides otherwise. A node
|
||||||
|
without it still queues cleanup items; another node or an operator reclaims them. An item that fails
|
||||||
|
eight times is abandoned rather than retried forever — it stays visible to an operator, parked
|
||||||
|
rather than discarded.
|
||||||
|
|
||||||
|
## Management plane — `admin`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | expose `/internal/fileserver/**` |
|
||||||
|
| `orphan-minimum-age` | `1h` | how long an unreferenced object must exist before a scan may name it |
|
||||||
|
|
||||||
|
Publishing content and committing its record are two steps. Anything younger than
|
||||||
|
`orphan-minimum-age` is assumed to be mid-commit rather than abandoned; shortening this makes
|
||||||
|
concurrent uploads look like orphans.
|
||||||
|
|
||||||
|
## Front-proxy delegation — `nginx`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | emit `X-Accel-Redirect` instead of a body |
|
||||||
|
| `internal-prefix` | `/__files/` | must be an `internal` location resolving to the content root |
|
||||||
|
| `object-suffix` | `.bin` | layout suffix the proxy appends |
|
||||||
|
| `minimum-size` | `16MB` | below this the application serves the transfer itself |
|
||||||
|
|
||||||
|
Delegation is decided only after authorization and the READY gate, so an internal redirect can only
|
||||||
|
ever name content the caller was already allowed to read.
|
||||||
|
|
||||||
|
## Protocols — `tus`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | expose the tus 1.0 endpoints |
|
||||||
|
|
||||||
|
The HTTPbis resumable-upload draft-12 surface is experimental and documented in
|
||||||
|
[support-matrix.md](support-matrix.md).
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Fileserver — deviations from the design
|
||||||
|
|
||||||
|
The design specification and the implementation plan are frozen documents. Where implementation
|
||||||
|
found them under-specified or self-contradicting, the resolution is recorded here rather than by
|
||||||
|
editing the specification, and every entry names the test that pins the decision.
|
||||||
|
|
||||||
|
## Resolved inconsistencies in the state machine and contracts
|
||||||
|
|
||||||
|
### 1. `CREATED → FAILED` has no edge in the transition table
|
||||||
|
|
||||||
|
A create that fails after the record exists must end in `FAILED`, but the table has no direct edge.
|
||||||
|
The record therefore walks `CREATED → UPLOADING → FAILED`, which is also the honest reading: the
|
||||||
|
upload had been admitted before it failed.
|
||||||
|
|
||||||
|
Pinned by `UploadApplicationServiceTest` (application-core).
|
||||||
|
|
||||||
|
### 2. `ContentKey`'s alphabet admits a leading separator
|
||||||
|
|
||||||
|
The design's key pattern `[a-z0-9/_-]{16,200}` matches `/etc/passwd/...`. Rejecting an absolute path
|
||||||
|
at the value type would change a design-fixed contract, so the stricter shape check lives in
|
||||||
|
`PhysicalPathResolver`, per §12.2 rule 1 — the only place that turns an identifier into a path.
|
||||||
|
|
||||||
|
Pinned by `PhysicalPathResolverTest`.
|
||||||
|
|
||||||
|
### 3. `VERIFYING → DELETING` has no edge
|
||||||
|
|
||||||
|
Deleting a file that is mid-verification has no legal transition. The lifecycle service refuses it
|
||||||
|
with `409 FILE_NOT_READY` rather than inventing an edge, which matches the allowed-state list the
|
||||||
|
JPA `markDeleting` statement already enforced.
|
||||||
|
|
||||||
|
Pinned by `FileLifecycleServiceTest`.
|
||||||
|
|
||||||
|
### 4. `If-Match` is specified as an ETag but the lifecycle was designed around the row version
|
||||||
|
|
||||||
|
The HTTP contract sends an entity tag; the metadata store guards on a numeric version. The service
|
||||||
|
takes `Optional<String> expectedEtag` and compares against the record's strong validator, so the
|
||||||
|
precondition a client sends is the precondition that is checked.
|
||||||
|
|
||||||
|
Pinned by `FileLifecycleServiceTest`.
|
||||||
|
|
||||||
|
### 5. The filename policy left `:` intact
|
||||||
|
|
||||||
|
`C:\Windows\system.ini` sanitized to `C:Windowssystem.ini` — a drive-qualified name surviving into
|
||||||
|
display text and headers. `:` joined the structural strip set.
|
||||||
|
|
||||||
|
Pinned by `FileserverHardeningContractTest` and `AmbiguousFilesystemOperationDetectorTest`.
|
||||||
|
|
||||||
|
## Additions the design implies but does not specify
|
||||||
|
|
||||||
|
### 6. `fs_recovery_item`
|
||||||
|
|
||||||
|
§10.2 lists five core tables and none of them can hold the recovery queue, yet §29.3 requires one:
|
||||||
|
reconciliation reports files whose bytes and metadata disagree, and holding that list in memory
|
||||||
|
would lose exactly the cases a restart interrupted. Added in
|
||||||
|
`V2__fileserver_recovery_and_staging_cleanup.sql` with one open item per file, so repeated sweeps
|
||||||
|
update a worklist rather than accumulating a log.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||||
|
|
||||||
|
### 7. `fs_cleanup_item.upload_id`
|
||||||
|
|
||||||
|
A staging object is addressed by upload, not by file. Without this column a queued staging cleanup
|
||||||
|
could name only already-published content, so a cancelled or expired upload left bytes nothing could
|
||||||
|
find. Added in the same migration, with a check constraint that an item names exactly one target.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||||
|
|
||||||
|
### 8. `ContentReferenceLedger` and `StagingUploadLocator`
|
||||||
|
|
||||||
|
The orphan scan must ask whether a record still claims a physical object, and reconciliation must
|
||||||
|
map a file back to the upload that last staged it. Neither question is answerable through the
|
||||||
|
design's `FileMetadataStore` or `UploadSessionStore` as written. Rather than widen those
|
||||||
|
design-fixed interfaces, both are narrow single-method ports.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest` and `LocalOrphanScanAdapterTest`.
|
||||||
|
|
||||||
|
## Interpretations
|
||||||
|
|
||||||
|
### 9. Quota settlement is FIFO within a scope
|
||||||
|
|
||||||
|
Nothing links a reservation row to the upload that took it, and the design deliberately reclaims
|
||||||
|
stragglers by TTL and the `STALE_QUOTA_RESERVATION` cleanup type rather than threading a reservation
|
||||||
|
id through the upload session. `QuotaCommitGateway` therefore settles the oldest live reservation in
|
||||||
|
the file's namespace.
|
||||||
|
|
||||||
|
Which row closes does not change any quota decision: enforcement sums reserved and committed bytes
|
||||||
|
per scope and never reads an individual row. Concurrent uploads of different sizes can leave the
|
||||||
|
reserved total transiently high or low, and it converges as each settles. Durable usage with no live
|
||||||
|
reservation behind it — an upload that outlived its TTL — is still recorded, because a ledger that
|
||||||
|
silently under-counts is worse than one that is briefly imprecise.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||||
|
|
||||||
|
### 10. Zero copy is a channel transfer, not a file handoff
|
||||||
|
|
||||||
|
Task 22 asks for zero copy on local files; §19 forbids a `Path` leaving the storage adapter, and §5
|
||||||
|
of the plan forbids adding `Path` to the content store. WebFlux's zero-copy API takes a `Path`, so
|
||||||
|
that route is closed.
|
||||||
|
|
||||||
|
The servlet path takes the other one: `ZeroCopyDownloadGateway` receives a `WritableByteChannel` from
|
||||||
|
the transport and the storage adapter performs `FileChannel.transferTo` into it. That is a genuine
|
||||||
|
kernel-level transfer with no filesystem concept leaving storage. The reactive path continues to
|
||||||
|
stream with bounded demand.
|
||||||
|
|
||||||
|
Zero copy is an optimization with no observable difference: when storage declines, the response is
|
||||||
|
streamed and is byte-identical.
|
||||||
|
|
||||||
|
Pinned by `LocalStorageGatewayContractTest` and `ZeroCopyEligibilityTest`.
|
||||||
|
|
||||||
|
### 11. The Fileserver JPA stores are gated on the capability switch
|
||||||
|
|
||||||
|
The metadata store, session store, quota service, queues, ledger, and staging locator carry
|
||||||
|
`@ConditionalOnProperty(app.fileserver-platform.enabled)` even though the rest of
|
||||||
|
`adapter:outbound:persistence-jpa` is unconditional.
|
||||||
|
|
||||||
|
Without the gate, every composition root that includes the persistence module built these beans —
|
||||||
|
including `sample-portfolio`, which has no Fileserver — and each of them needs collaborators only
|
||||||
|
the Fileserver configuration provides. That is the same rule the design states for the transport
|
||||||
|
surface, applied to persistence: no surface appears merely because the dependency is present.
|
||||||
|
|
||||||
|
`FileStateMachine` is bound alongside them, in `FileserverStorageConfiguration`. It had no
|
||||||
|
production binding at all before, which made the metadata store unconstructible in any
|
||||||
|
component-scanned context.
|
||||||
|
|
||||||
|
Pinned by `SampleApplicationContextTest` (the capability off) and
|
||||||
|
`FileserverRuntimeAssemblyTest` (the capability on).
|
||||||
|
|
||||||
|
### 12. Transaction boundaries are owned by the application services, and are deliberately narrow
|
||||||
|
|
||||||
|
The design does not say where a transaction begins. The repository does:
|
||||||
|
`adapter:outbound:persistence-jpa` forbids a repository adapter from owning a `@Transactional`
|
||||||
|
boundary, and `application-core` owns them through `TransactionPort`. The Fileserver follows that
|
||||||
|
rule — every `Jpa*` store here declares no `@Transactional` of its own.
|
||||||
|
|
||||||
|
What is specific to this capability is how narrow the boundaries are. A boundary covers a contiguous
|
||||||
|
run of metadata writes and **stops before every storage call**, because a filesystem operation
|
||||||
|
inside a database transaction would hold a connection for the length of a byte transfer. The upload
|
||||||
|
path therefore has three boundaries, not one: acquire the lease, transfer the bytes, commit the
|
||||||
|
offset.
|
||||||
|
|
||||||
|
Where several stores must agree, they share one boundary:
|
||||||
|
|
||||||
|
| Unit | Why it is one boundary |
|
||||||
|
| --- | --- |
|
||||||
|
| reserve quota + insert record + create session | a reservation that outlived a failed insert holds capacity for a file that never existed |
|
||||||
|
| READY transition + quota commit | a finished file whose reservation was never converted holds capacity until the reservation expires |
|
||||||
|
| `markDeleting` + enqueue cleanup | a file that stopped being reachable with nothing queued to reclaim it is never collected |
|
||||||
|
| content delete settlement: reclaim + retire record + close queue item | half of it leaves the item to be retried against content that no longer exists |
|
||||||
|
|
||||||
|
What this cannot make atomic is the storage/metadata seam itself — no database boundary could. That
|
||||||
|
seam is exactly what the ambiguous-completion path and the reconciler exist for, and the one
|
||||||
|
hand-written compensation that remains (staging creation failing after the records committed) is
|
||||||
|
there for the same reason.
|
||||||
|
|
||||||
|
Pinned by `FileserverRoundTripContractTest` against real PostgreSQL; the application tests use
|
||||||
|
`DirectTransactions`, which runs a boundary inline and counts it.
|
||||||
|
|
||||||
|
## Not implemented
|
||||||
|
|
||||||
|
### `AsyncContentStore`, `CapacityAwareContentStore`, `CopyCapableContentStore`, `DelegatedDownloadStore`
|
||||||
|
|
||||||
|
Four optional content-store SPIs are declared in `application-core` with no implementation. Each is
|
||||||
|
an extension point for a backend this template does not ship:
|
||||||
|
|
||||||
|
- `AsyncContentStore` — for a backend whose native client is non-blocking. The local platform is
|
||||||
|
blocking, and the reactive transport bridges to it on a dedicated I/O scheduler.
|
||||||
|
- `CapacityAwareContentStore` — capacity is reported through `StorageHealthPort` and
|
||||||
|
`StorageUsageProbe`, which the local platform implements.
|
||||||
|
- `CopyCapableContentStore` — server-side copy is delivered by `CopyContentGateway`; the local
|
||||||
|
platform has no cheaper primitive than a streamed copy.
|
||||||
|
- `DelegatedDownloadStore` — delegation is delivered at the transport boundary by the nginx
|
||||||
|
`X-Accel-Redirect` strategy, which needs no store participation.
|
||||||
|
|
||||||
|
`ContentStoreCapabilities` reports what the running store actually supports, so no unimplemented SPI
|
||||||
|
is advertised as available.
|
||||||
|
|
||||||
|
## Known deviation from the repository's application-layer contract
|
||||||
|
|
||||||
|
### 5. Fileserver application services are not `CommandUseCase` / `QueryUseCase`
|
||||||
|
|
||||||
|
`src/application-core/CLAUDE.md` requires every inbound port implementation to extend
|
||||||
|
`CommandUseCase` or `QueryUseCase` and to carry `@UseCaseCapability`, which declares its transaction
|
||||||
|
mode, idempotency and repository access. The Fileserver instead exposes multi-method services —
|
||||||
|
`UploadApplicationService`, `DownloadApplicationService`, `FileLifecycleService`,
|
||||||
|
`FileserverAdminService` and their `Default*` implementations.
|
||||||
|
|
||||||
|
This is a real deviation, not an oversight, and it is unenforced: the ArchUnit rules
|
||||||
|
`inbound_port_implementations_end_with_use_case` and
|
||||||
|
`inbound_port_implementations_declare_capability` only match types that implement `UseCase`, so a
|
||||||
|
service that never does is silently exempt. The capability contract that every other feature in
|
||||||
|
this repository declares is therefore absent here.
|
||||||
|
|
||||||
|
Two things follow from it. The transaction mode of each operation is expressed only by which
|
||||||
|
`TransactionPort` method the body happens to call, rather than declared and checked. And the
|
||||||
|
application layer holds transport policy it would not hold if each operation were a use case with
|
||||||
|
its own command: HTTP status codes on `FileserverErrorCode`, `Range` and conditional-request
|
||||||
|
parsing in `api.transfer`, and `Content-Disposition` construction.
|
||||||
|
|
||||||
|
The status mapping in particular is a deliberate trade rather than an accident. It lives in
|
||||||
|
`application-core` so the servlet transport, the reactive transport and the Nginx delegation path
|
||||||
|
cannot answer the same failure with three different statuses. Moving it to the transport layer
|
||||||
|
resolves the layering complaint and reintroduces exactly that drift, which is why this is an
|
||||||
|
architecture decision rather than a cleanup.
|
||||||
|
|
||||||
|
**Status: open, deliberately unresolved in this change set.** Closing it means roughly thirty
|
||||||
|
command/query use cases, a decision about where the shared status vocabulary lives, and a change to
|
||||||
|
the ArchUnit rules so a service that bypasses the contract fails the build instead of being exempt
|
||||||
|
from it. That belongs in its own ADR with its own review, and doing it inside a correctness patch
|
||||||
|
would mix a large mechanical refactor into changes that need to be readable.
|
||||||
|
|
||||||
|
Nothing here is pinned by a test, because the deviation is the absence of a constraint. The next
|
||||||
|
step is the ADR, not another test.
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Fileserver HTTP contract
|
||||||
|
|
||||||
|
Every public endpoint is listed here. `FileserverDocumentationCoverageTest` scans the controllers
|
||||||
|
and fails if one is missing, so this file cannot silently fall behind the code.
|
||||||
|
|
||||||
|
## Public endpoints
|
||||||
|
|
||||||
|
| Method | Path | Success | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/v1/files` | `201` READY, `202` VERIFYING | multipart single upload |
|
||||||
|
| POST | `/v1/files:raw` | `201`, `202` | the whole request body is the file |
|
||||||
|
| POST | `/v1/files:batch` | `200` | ordered per-part results; explicitly non-atomic |
|
||||||
|
| GET | `/v1/files/{fileId}` | `200` | public metadata; never a content key or path |
|
||||||
|
| GET | `/v1/files/{fileId}/content` | `200`, `206`, `304` | download |
|
||||||
|
| HEAD | `/v1/files/{fileId}/content` | `200`, `304` | identical headers, no body |
|
||||||
|
| DELETE | `/v1/files/{fileId}` | `202`, `204` | logical delete first |
|
||||||
|
| POST | `/v1/files/{fileId}:copy` | `202` | create-only target |
|
||||||
|
| POST | `/v1/files/{fileId}:move` | `200` | logical namespace change only |
|
||||||
|
| OPTIONS | `/v1/uploads` | `204` | tus capability discovery |
|
||||||
|
| POST | `/v1/uploads` | `201` | tus creation |
|
||||||
|
| HEAD | `/v1/uploads/{uploadId}` | `204` | tus offset |
|
||||||
|
| PATCH | `/v1/uploads/{uploadId}` | `204` | tus append |
|
||||||
|
| DELETE | `/v1/uploads/{uploadId}` | `204` | tus termination |
|
||||||
|
| POST | `/v1/experimental/draft12/uploads` | `201` | Experimental; off by default |
|
||||||
|
| PATCH | `/v1/experimental/draft12/uploads/{uploadId}` | `204` | Experimental; off by default |
|
||||||
|
|
||||||
|
## Management endpoints
|
||||||
|
|
||||||
|
Reachable only where both `app.fileserver-platform.enabled=true` and
|
||||||
|
`app.fileserver-platform.admin.enabled=true`, gated at the servlet chain on
|
||||||
|
`app.fileserver-platform.security.admin-roles`, and intended for a management
|
||||||
|
port rather than the public one.
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|---|---|
|
||||||
|
| GET | `/internal/fileserver/storage-health` |
|
||||||
|
| GET | `/internal/fileserver/capabilities` |
|
||||||
|
| GET | `/internal/fileserver/orphans` |
|
||||||
|
| POST | `/internal/fileserver/orphans:reconcile` |
|
||||||
|
| POST | `/internal/fileserver/files/{fileId}:reverify` |
|
||||||
|
| POST | `/internal/fileserver/files/{fileId}:force-delete` |
|
||||||
|
| GET | `/internal/fileserver/uploads/incomplete` |
|
||||||
|
| POST | `/internal/fileserver/uploads:cleanup` |
|
||||||
|
|
||||||
|
## Status codes
|
||||||
|
|
||||||
|
| Status | Condition |
|
||||||
|
|---:|---|
|
||||||
|
| `200` | metadata, full GET, batch result, move |
|
||||||
|
| `201` | file or upload created |
|
||||||
|
| `202` | verification or physical cleanup deferred |
|
||||||
|
| `204` | append, cancel, bodyless update |
|
||||||
|
| `206` | satisfiable Range |
|
||||||
|
| `304` | validator matched on GET or HEAD |
|
||||||
|
| `400` | malformed header or header combination |
|
||||||
|
| `401` | unauthenticated |
|
||||||
|
| `403` / `404` | denied, or hidden under the existence-hiding profile |
|
||||||
|
| `409` | state, offset, or lease conflict |
|
||||||
|
| `410` | expired upload resource |
|
||||||
|
| `411` | `require-content-length` profile with no length |
|
||||||
|
| `412` | precondition failed |
|
||||||
|
| `413` | size or quota policy violation |
|
||||||
|
| `415` | upload media type not accepted |
|
||||||
|
| `416` | unsatisfiable Range; carries the real length |
|
||||||
|
| `422` | digest, signature, or scanner rejection |
|
||||||
|
| `429` | transfer admission or rate limit |
|
||||||
|
| `503` | storage or scanner unavailable |
|
||||||
|
| `504` | downstream timeout |
|
||||||
|
| `507` | out of storage capacity |
|
||||||
|
|
||||||
|
## Failure body
|
||||||
|
|
||||||
|
Every failure answers `application/problem+json` with a stable code and its URN:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "urn:fileserver:problem:upload-offset-mismatch",
|
||||||
|
"title": "Upload offset mismatch",
|
||||||
|
"status": 409,
|
||||||
|
"code": "UPLOAD_OFFSET_MISMATCH",
|
||||||
|
"retryable": true,
|
||||||
|
"ambiguous": false,
|
||||||
|
"reconciliationRequired": false,
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server-side exception message never appears. `ambiguous` is the field a client must read before
|
||||||
|
retrying: an ambiguous failure may already have taken effect.
|
||||||
|
|
||||||
|
## Header contract
|
||||||
|
|
||||||
|
| Header | Contract |
|
||||||
|
|---|---|
|
||||||
|
| `Content-Type` | client value is a claim; the verified type is stored separately |
|
||||||
|
| `Content-Disposition` | `attachment` by default; scriptable types are never inline |
|
||||||
|
| `Accept-Ranges` | `bytes` |
|
||||||
|
| `Range` | single range by default; multi-range only under an explicit budget |
|
||||||
|
| `Content-Range` | actual range on `206`; the unsatisfied form on `416` |
|
||||||
|
| `ETag` | strong validator derived from the SHA-256 |
|
||||||
|
| `Last-Modified` | metadata publication instant, never a filesystem timestamp |
|
||||||
|
| `Cache-Control` | `private, no-store` by default |
|
||||||
|
| `X-Content-Type-Options` | always `nosniff` on a download |
|
||||||
|
| `Retry-After` | on retryable `409`, `429`, `503`, and `504` |
|
||||||
|
| `X-Accel-Redirect` | internal only; never forwarded to a client |
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Fileserver runbooks
|
||||||
|
|
||||||
|
Each runbook names the exact metric that fires it and the exact command that resolves it. A runbook
|
||||||
|
whose trigger is "someone noticed" is not actionable, so every one below starts from a signal.
|
||||||
|
|
||||||
|
## Storage full
|
||||||
|
|
||||||
|
**Signal** — `fileserver.quota{result="rejected"}` rising, or `507` responses appearing.
|
||||||
|
|
||||||
|
Storage capacity is exhausted or the high-water guard tripped. Uploads are rejected before any bytes
|
||||||
|
are written, so nothing is corrupt; the system is refusing work it cannot complete.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s $ADMIN/internal/fileserver/storage-health | jq '.usedFraction, .usableBytes'
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/uploads:cleanup?maxItems=500&maxBytes=10737418240"
|
||||||
|
curl -s "$ADMIN/internal/fileserver/orphans?limit=200" | jq '[.[].sizeBytes] | add'
|
||||||
|
```
|
||||||
|
|
||||||
|
Drain the cleanup backlog first — it reclaims space the system already knows is dead. Only then
|
||||||
|
consider an orphan reconcile, and start with a dry run.
|
||||||
|
|
||||||
|
## Orphan growth
|
||||||
|
|
||||||
|
**Signal** — `fileserver.cleanup{result="skipped"}` climbing, or the orphan scan returning more
|
||||||
|
objects each run.
|
||||||
|
|
||||||
|
Physical objects exist with no metadata record pointing at them. This is not immediately dangerous —
|
||||||
|
nothing serves them — but it consumes capacity indefinitely.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Always look first. A reconcile without dryRun=false is a plan, not an action.
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/orphans:reconcile" \
|
||||||
|
-H 'content-type: application/json' -d '{"limit":100}' | jq '.candidates'
|
||||||
|
|
||||||
|
# Apply only the fingerprints you were just shown.
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/orphans:reconcile" \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"dryRun":false,"limit":100,"maxBytes":1073741824,
|
||||||
|
"expectedFingerprints":["<from the dry run>"],"reasonCode":"ORPHAN_GROWTH_RUNBOOK"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Echoing the fingerprints is the safety property: an object that changed between the scan and the
|
||||||
|
apply is skipped rather than deleted.
|
||||||
|
|
||||||
|
## Verification backlog
|
||||||
|
|
||||||
|
**Signal** — `fileserver.verification.queue{age_bucket="old"}` non-zero, or files sitting in
|
||||||
|
VERIFYING.
|
||||||
|
|
||||||
|
A verifier is slow or unavailable. Files stay non-public, which is the correct failure direction: a
|
||||||
|
`RETRY` verdict never becomes an `ACCEPT`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s $ADMIN/internal/fileserver/capabilities | jq '.storageType'
|
||||||
|
# Once the verifier is healthy, quarantined files can be re-examined individually.
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/files/$FILE_ID:reverify"
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not clear the backlog by disabling verification. A file that reached READY without an accepting
|
||||||
|
verdict cannot be distinguished later from one that was verified.
|
||||||
|
|
||||||
|
## NFS ambiguity
|
||||||
|
|
||||||
|
**Signal** — problem documents carrying `"ambiguous": true`, or
|
||||||
|
`fileserver.transfer.interruption{reason="stale_handle"}`.
|
||||||
|
|
||||||
|
An operation's outcome could not be determined: the response was lost after the write or rename may
|
||||||
|
have landed. These are never retried automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The recovery queue holds the files awaiting a decision.
|
||||||
|
curl -s "$ADMIN/internal/fileserver/uploads/incomplete?limit=100" | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Reconciliation compares the physical size and digest against the record and only confirms READY when
|
||||||
|
all four of key, size, digest, and version agree. Anything short of that is reported, never guessed.
|
||||||
|
|
||||||
|
## PVC remount
|
||||||
|
|
||||||
|
**Signal** — startup failure naming "atomic move", "same file store", or "not writable".
|
||||||
|
|
||||||
|
The volume was remounted somewhere the probe can no longer prove a required capability. The
|
||||||
|
application refuses traffic rather than serving from storage it cannot publish to atomically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
kubectl logs job/fileserver-pvc-certification
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare the printed tuple with the certified one in `docs/fileserver/storage-certification.md`. A
|
||||||
|
mismatch in CSI driver, StorageClass, access mode, or mount options is the cause; the certification
|
||||||
|
does not carry across it.
|
||||||
|
|
||||||
|
## Nginx delegation failure
|
||||||
|
|
||||||
|
**Signal** — `fileserver.download.delegation{delegated="true"}` with client-visible `404`s.
|
||||||
|
|
||||||
|
The internal location is misconfigured, so the proxy cannot resolve the redirect it was handed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The internal prefix must resolve to the content root and must be marked `internal`.
|
||||||
|
grep -A5 '__files' infra/fileserver/nginx/nginx.conf
|
||||||
|
curl -s $ADMIN/internal/fileserver/capabilities | jq '.capabilities.delegatedDownload'
|
||||||
|
```
|
||||||
|
|
||||||
|
Turning delegation off is a safe immediate mitigation: the application serves the transfer itself,
|
||||||
|
slower but correct.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
app.fileserver-platform.nginx.enabled=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup backlog
|
||||||
|
|
||||||
|
**Signal** — `fileserver.cleanup{result="deferred"}` rising, or reclaimed bytes flat while deletes
|
||||||
|
continue.
|
||||||
|
|
||||||
|
Items are being deferred faster than they drain. The usual cause is an active writer lease still
|
||||||
|
holding staging objects, which is correct behaviour, not a fault.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$ADMIN/internal/fileserver/uploads/incomplete?limit=100" \
|
||||||
|
| jq '[.[] | select(.leaseUntil != null)] | length'
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/uploads:cleanup?maxItems=500&maxBytes=10737418240"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the deferrals are all `ACTIVE_WRITER_LEASE`, the backlog resolves itself as those uploads expire.
|
||||||
|
Never delete staging content to clear a backlog: an upload that is mid-flight will corrupt.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Fileserver security model
|
||||||
|
|
||||||
|
## The rule everything else follows
|
||||||
|
|
||||||
|
Uploaded content is attacker-controlled. Every guard below exists because some part of the request
|
||||||
|
— the filename, the declared media type, the range, the offset — is a value the caller chose.
|
||||||
|
|
||||||
|
## Path safety
|
||||||
|
|
||||||
|
A client value never becomes a path. The physical key is server-generated, and `ContentKey`'s
|
||||||
|
character class excludes `.` entirely, so no traversal or extension-shaped segment survives
|
||||||
|
validation. `DefaultPhysicalPathResolver` is the only place an identifier becomes a `Path`, and it
|
||||||
|
normalizes and re-checks containment after construction rather than trusting the input.
|
||||||
|
|
||||||
|
Symlink refusal happens at open time, not only at construction. A parent directory can be replaced
|
||||||
|
between the two, so a check that ran only at path-building time would be a race, not a guard.
|
||||||
|
|
||||||
|
## Filename handling
|
||||||
|
|
||||||
|
`OriginalFilenamePolicy` strips path separators, NUL, quoting characters, and the colon — the last
|
||||||
|
because on Windows it opens both a drive reference and an NTFS alternate data stream, so a name that
|
||||||
|
keeps it is still path-shaped after the slashes are gone. Control characters and bidirectional
|
||||||
|
overrides are removed, dot runs collapsed, reserved device names guarded, and the result is bounded
|
||||||
|
in UTF-8 bytes.
|
||||||
|
|
||||||
|
The sanitized name is display data. It is never used to build a key, and it reaches a header only
|
||||||
|
through `ContentDispositionFactory`, which restricts the ASCII form and percent-encodes the UTF-8
|
||||||
|
form.
|
||||||
|
|
||||||
|
## Content type
|
||||||
|
|
||||||
|
The client's `Content-Type` is stored as a claim. The verified type comes from the verification
|
||||||
|
pipeline, and only the verified type is served. A claimed type that contradicts the content is
|
||||||
|
quarantined rather than corrected.
|
||||||
|
|
||||||
|
Scriptable types are never served inline, whatever the caller asked for: serving stored HTML or SVG
|
||||||
|
inline from an upload origin is a stored cross-site scripting primitive. Every download also carries
|
||||||
|
`X-Content-Type-Options: nosniff`.
|
||||||
|
|
||||||
|
## Verification precedence
|
||||||
|
|
||||||
|
`REJECT > QUARANTINE > RETRY > ACCEPT`. A verifier that times out or throws is `RETRY`, never a
|
||||||
|
silent pass, and an empty verifier chain answers `RETRY` rather than accepting. A file becomes
|
||||||
|
publicly readable only after an `ACCEPT`.
|
||||||
|
|
||||||
|
## Range safety
|
||||||
|
|
||||||
|
The range budget is enforced before content is opened, so a request naming many ranges is rejected
|
||||||
|
without amplifying into storage work. An unsatisfiable range answers `416` with the real length and
|
||||||
|
opens nothing.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
Every public operation calls the injected `FileAccessPolicy` before any quota reservation or storage
|
||||||
|
mutation, so a denial leaves no record, no reservation, and no staging object. Startup refuses to
|
||||||
|
run a production profile with an allow-all policy.
|
||||||
|
|
||||||
|
## Delegation
|
||||||
|
|
||||||
|
`X-Accel-Redirect` is emitted only after authorization and the READY gate, and only for a full,
|
||||||
|
unconditional response. The internal prefix must be an `internal` Nginx location; the front proxy
|
||||||
|
also strips any client-supplied delegation header so a caller cannot name an internal object.
|
||||||
|
|
||||||
|
## Telemetry
|
||||||
|
|
||||||
|
No metric label, span attribute, or audit record carries a file id, upload id, filename, path, or
|
||||||
|
user id. Where correlation is needed the value is a keyed HMAC fingerprint — keyed because the
|
||||||
|
identifier space is enumerable and an unkeyed digest of it is reversible by brute force.
|
||||||
|
|
||||||
|
## Ambiguous failures
|
||||||
|
|
||||||
|
A failure whose operation may already have taken effect is reported as ambiguous and is never
|
||||||
|
retryable. On a network filesystem a lost response is indistinguishable from a rejection at the
|
||||||
|
socket level, so anything not provably safe is treated as ambiguous and sent to reconciliation.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Storage certification
|
||||||
|
|
||||||
|
## Why a certification is per-volume
|
||||||
|
|
||||||
|
Atomic rename, same-file-store guarantees, and symlink refusal are properties of a specific
|
||||||
|
filesystem behind a specific mount — not of "Kubernetes" or "a PVC". Change the CSI driver, the
|
||||||
|
StorageClass, the access mode, the backend, or the mount options and any of them can differ. A
|
||||||
|
certification that does not name all five is not transferable.
|
||||||
|
|
||||||
|
## What is certified
|
||||||
|
|
||||||
|
| Property | Why it matters |
|
||||||
|
|---|---|
|
||||||
|
| Same file store for staging and content | A rename across stores is a copy, so publication stops being atomic. |
|
||||||
|
| Atomic rename | The publish path's default strategy. |
|
||||||
|
| Atomic create (`O_EXCL`) | Makes a publish create-only rather than a silent overwrite. |
|
||||||
|
| Symlink refusal | Stops a replaced parent from redirecting a write outside the root. |
|
||||||
|
| Ranged read | The download contract depends on it. |
|
||||||
|
|
||||||
|
## Running the certification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
kubectl logs job/fileserver-pvc-certification
|
||||||
|
```
|
||||||
|
|
||||||
|
The job writes a machine-readable result to the claim itself, carrying the full tuple:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kubernetesVersion": "...",
|
||||||
|
"csiDriver": "...",
|
||||||
|
"storageClass": "...",
|
||||||
|
"accessMode": "ReadWriteOnce",
|
||||||
|
"backend": "ext2/ext3",
|
||||||
|
"mountOptions": "rw,relatime",
|
||||||
|
"atomicMove": true,
|
||||||
|
"sameFileStore": true,
|
||||||
|
"atomicCreate": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The job fails closed: a volume whose staging and content areas are on different stores is not
|
||||||
|
certified, because its publish would silently degrade to a copy.
|
||||||
|
|
||||||
|
## Network filesystems
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f infra/fileserver/nfs/compose.yml up -d
|
||||||
|
FILESERVER_NFS_TESTS=true ./gradlew :adapter:outbound:fileserver:test
|
||||||
|
```
|
||||||
|
|
||||||
|
The mount is `hard`, deliberately. A `soft` mount converts a slow server into a short write, which
|
||||||
|
is exactly the corruption this design refuses to accept.
|
||||||
|
|
||||||
|
## Startup enforcement
|
||||||
|
|
||||||
|
`FileserverStartupValidator` re-runs the probe at boot and refuses to accept traffic when a required
|
||||||
|
capability is missing — `ATOMIC_MOVE_REQUIRED` on a filesystem that cannot prove an atomic move
|
||||||
|
fails closed rather than degrading silently.
|
||||||
|
|
||||||
|
## Adding a new store
|
||||||
|
|
||||||
|
Extend `ContentStoreContract` and pass it. A prose claim of compatibility is not accepted; the
|
||||||
|
contract is executable precisely so a future object-storage adapter has to demonstrate the same
|
||||||
|
offset, digest, and create-only behaviour the local store does.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Fileserver support matrix
|
||||||
|
|
||||||
|
A support level here is a claim about evidence, not about intent. Every row names the CI job that
|
||||||
|
produces that evidence; `FileserverDocumentationCoverageTest` fails the build if a row names a job
|
||||||
|
that does not exist, so a level can never outlive the test that justified it.
|
||||||
|
|
||||||
|
## Levels
|
||||||
|
|
||||||
|
| Level | What it means |
|
||||||
|
|---|---|
|
||||||
|
| Stable | Certified on every pull request. Contract changes are breaking changes. |
|
||||||
|
| Beta | Certified nightly. The contract may still change with a deprecation notice. |
|
||||||
|
| Limited | Certified on the release gate only, under stated constraints. |
|
||||||
|
| Compatibility | Accepted but not optimized; known caveats are listed inline. |
|
||||||
|
| Experimental | Off by default, unratified upstream, may change without notice. |
|
||||||
|
|
||||||
|
## Runtime profiles
|
||||||
|
|
||||||
|
| Profile | Level | CI job |
|
||||||
|
|---|---|---|
|
||||||
|
| Local filesystem (ext4) content store | Stable | `fileserver-local-ext4-contract` |
|
||||||
|
| Spring MVC transport (raw, multipart, batch, download) | Stable | `fileserver-http-contract` |
|
||||||
|
| Spring WebFlux transport | Experimental | `fileserver-http-contract` |
|
||||||
|
| Path, filename, range, and problem-detail hardening | Stable | `fileserver-security-suite` |
|
||||||
|
| Bounded-memory transfer | Stable | `fileserver-bounded-memory` |
|
||||||
|
| Application and architecture invariants | Stable | `fileserver-unit-and-architecture` |
|
||||||
|
| Runtime assembly (the capability starts with the flag on) | Stable | `fileserver-unit-and-architecture` |
|
||||||
|
| tus 1.0 resumable uploads | Stable | `fileserver-http-contract` |
|
||||||
|
| Crash-recovery matrix | Beta | `fileserver-process-kill-matrix` |
|
||||||
|
| NFSv4 ambiguity handling | Beta | `fileserver-nfs-ambiguity` |
|
||||||
|
| Large-file and slow-client performance | Beta | `fileserver-large-file-performance` |
|
||||||
|
| Multi-instance writer lease | Beta | `fileserver-multi-instance-lease` |
|
||||||
|
| Kubernetes ReadWriteOnce PVC | Limited | `fileserver-pvc-certification` (manifest checks in CI; cluster run is operator-driven) |
|
||||||
|
| Nginx `X-Accel-Redirect` delegation | Limited | `fileserver-http-contract` |
|
||||||
|
| Telemetry sensitive-data suppression | Stable | `fileserver-sensitive-telemetry-scan` |
|
||||||
|
| Documentation and support-claim coverage | Stable | `fileserver-documentation-gate` |
|
||||||
|
| Full release verification | Stable | `fileserver-full-verification` |
|
||||||
|
| HTTP resumable uploads draft-12 | Experimental | `fileserver-http-contract` |
|
||||||
|
|
||||||
|
### Why WebFlux is Experimental, not Stable
|
||||||
|
|
||||||
|
The reactive router, handlers and readers are now wired: `FileserverReactiveConfiguration`
|
||||||
|
contributes the scheduler, the handlers and a `RouterFunction` bean under
|
||||||
|
`@ConditionalOnWebApplication(type = REACTIVE)` plus the platform master switch. Previously nothing
|
||||||
|
built them at all, so "Stable" described the source tree rather than a running server.
|
||||||
|
|
||||||
|
It stays `Experimental` because the shipped composition cannot select it. `adapter:inbound:web`
|
||||||
|
also puts `DispatcherServlet` on the classpath — deliberately, so adding `spring-webflux` does not
|
||||||
|
drag a second embedded server onto the runtime — and Boot's application-type deduction therefore
|
||||||
|
resolves SERVLET. A fork that removes the servlet stack and adds a reactive server gets working
|
||||||
|
routes without editing any Fileserver code; the shipped template does not exercise that path.
|
||||||
|
|
||||||
|
Raising it to Stable requires a contract job that drives the routes over a running reactive server
|
||||||
|
rather than through direct construction.
|
||||||
|
|
||||||
|
## Explicitly not claimed
|
||||||
|
|
||||||
|
These have no job, and therefore no claim:
|
||||||
|
|
||||||
|
- An automated Kubernetes cluster result. `fileserver-pvc-certification` validates the manifest on
|
||||||
|
every release and applies it only when a release cluster is configured; without one it warns and
|
||||||
|
records that nothing was certified. The cluster tuple is produced by an operator and read from
|
||||||
|
[storage-certification.md](storage-certification.md).
|
||||||
|
|
||||||
|
- Kubernetes ReadWriteMany PVC. Concurrent writers across nodes are not certified.
|
||||||
|
- Windows NTFS as a production storage root. The filename policy strips the characters NTFS
|
||||||
|
reserves, but no job certifies the publish path there.
|
||||||
|
- Object storage as a content store. The contract exists (`ContentStoreContract`) but no adapter
|
||||||
|
implements it yet.
|
||||||
|
- Server-side malware scanning. The verification pipeline has the port and the verdict precedence;
|
||||||
|
no scanner is shipped.
|
||||||
|
|
||||||
|
## Where the rest is written down
|
||||||
|
|
||||||
|
- [configuration.md](configuration.md) — every `app.fileserver-platform.*` key, its default, and the
|
||||||
|
conditions that fail startup rather than degrade.
|
||||||
|
- [design-deviations.md](design-deviations.md) — where the implementation departs from the frozen
|
||||||
|
design, why, and the test that pins each decision.
|
||||||
|
- [http-contract.md](http-contract.md) — the wire contract.
|
||||||
|
- [security.md](security.md) — the threat model and what enforces each control.
|
||||||
|
- [operations.md](operations.md) — runbooks, each starting from a metric.
|
||||||
|
- [storage-certification.md](storage-certification.md) — how a volume is certified.
|
||||||
|
- [upgrade-guide.md](upgrade-guide.md) — what changes between versions.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Fileserver upgrade guide
|
||||||
|
|
||||||
|
## Enabling the capability
|
||||||
|
|
||||||
|
The Fileserver ships off. Nothing is registered — no endpoint, no thread pool, no metric — until it
|
||||||
|
is enabled explicitly.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ca-skeleton:
|
||||||
|
fileserver:
|
||||||
|
enabled: true
|
||||||
|
instance-id: ${HOSTNAME}
|
||||||
|
default-namespace: default
|
||||||
|
observability:
|
||||||
|
fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
`instance-id` must be unique per instance: it is the writer-lease owner, and two nodes sharing one
|
||||||
|
would both believe they hold the same lease.
|
||||||
|
|
||||||
|
`fingerprint-key` is required and has no default. Startup fails without it rather than falling back
|
||||||
|
to an unkeyed digest, which would be reversible for an enumerable identifier space.
|
||||||
|
|
||||||
|
## Optional surfaces
|
||||||
|
|
||||||
|
Each is a separate switch, and each defaults to off:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ca-skeleton:
|
||||||
|
fileserver:
|
||||||
|
admin:
|
||||||
|
enabled: false # management plane; intended for a management port
|
||||||
|
tus:
|
||||||
|
enabled: false # tus 1.0 Stable
|
||||||
|
httpbis-draft12:
|
||||||
|
enabled: false # Experimental; unratified, may change without notice
|
||||||
|
nginx:
|
||||||
|
enabled: false # front-proxy delegation; needs a validated internal location
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database schema
|
||||||
|
|
||||||
|
The metadata schema is installed as a capability migration and starts inactive:
|
||||||
|
|
||||||
|
```
|
||||||
|
V1__create_fileserver_metadata.sql → capability_schema_registry: jpa-fileserver-metadata-v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Activate it deliberately. Enabling the capability without an activated schema fails at startup
|
||||||
|
rather than at the first upload.
|
||||||
|
|
||||||
|
## Choosing a publish mode
|
||||||
|
|
||||||
|
| Mode | When |
|
||||||
|
|---|---|
|
||||||
|
| `atomic-move-preferred` | Default. Uses an atomic rename when the probe proves one, else a metadata pointer. |
|
||||||
|
| `atomic-move-required` | Fail closed. Refuses to start on storage that cannot prove an atomic move. |
|
||||||
|
| `metadata-pointer` | For storage without atomic rename; publication is the metadata commit. |
|
||||||
|
|
||||||
|
Pick `atomic-move-required` when the storage is certified and you want a misconfiguration to surface
|
||||||
|
at boot rather than at publish time.
|
||||||
|
|
||||||
|
## Behaviour that will surprise you
|
||||||
|
|
||||||
|
- **A delete answers `202`, not `204`, when content still exists.** The file is already unreadable;
|
||||||
|
the physical reclaim is deferred. Treating `202` as a failure will produce spurious retries.
|
||||||
|
- **A batch upload answers `200` even when parts failed.** The batch is explicitly non-atomic, and a
|
||||||
|
single status could not report a partial outcome honestly. Read `results[].problem`.
|
||||||
|
- **An ambiguous failure must not be retried.** Check `"ambiguous": true` in the problem document.
|
||||||
|
- **`If-Match` takes the strong ETag, not a version number.** A client can only assert about the
|
||||||
|
representation it was actually served.
|
||||||
|
- **Inline rendering is refused for scriptable types** even when the caller asks for it.
|
||||||
|
|
||||||
|
## Verifying an upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :application-core:check :adapter:inbound:web:check \
|
||||||
|
:adapter:outbound:fileserver:check --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*Fileserver*' --console=plain
|
||||||
|
```
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# HTTP Client Platform — Configuration Reference
|
||||||
|
|
||||||
|
Every outbound call resolves exactly one **Named Client Profile**. The whole capability lives under
|
||||||
|
the `app.httpclient` prefix: profiles under `app.httpclient.clients[N]`, Dynamic Target policies
|
||||||
|
under `app.httpclient.dynamic-targets[N]`.
|
||||||
|
|
||||||
|
Design §30.1 forbids a production profile from inheriting large framework defaults. Anything a
|
||||||
|
production deployment must decide has either no default or an unusable one, and
|
||||||
|
`HttpClientStartupValidator` fails the context rather than guessing.
|
||||||
|
|
||||||
|
## The master switch
|
||||||
|
|
||||||
|
| Property | Type | Default | Environment |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `app.httpclient.enabled` | boolean | `false` | `APP_HTTPCLIENT_ENABLED` |
|
||||||
|
|
||||||
|
Off is the shipped state and it is a structural one. `HttpClientPlatformAutoConfiguration` lives in
|
||||||
|
a package the composition root's component scan excludes, so while the switch is absent or false the
|
||||||
|
class is never processed and neither is anything it imports: no property is bound, and no transport
|
||||||
|
provider, connection pool, TLS context, credential, thread, gateway or actuator endpoint exists. A
|
||||||
|
malformed HTTP client setting cannot fail the startup of a deployment that never wanted outbound
|
||||||
|
HTTP.
|
||||||
|
|
||||||
|
Anything that is not exactly `true` — `yes`, `1`, blank — leaves the platform off. Turning it on
|
||||||
|
with no client declared is a startup failure carrying `HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`: a
|
||||||
|
platform with nothing to call still holds transport providers and gateways no caller can reach.
|
||||||
|
|
||||||
|
## Declaring clients from the environment
|
||||||
|
|
||||||
|
Clients are an indexed list carrying their own `name`, not a map keyed by name. A map key becomes a
|
||||||
|
segment of the environment variable and the relaxed binder normalises it, so `payment-api` and
|
||||||
|
`payment_api` would arrive as one entry with nothing said about the one that was lost. Both a
|
||||||
|
duplicate name and a name that collides once normalised fail startup.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
APP_HTTPCLIENT_ENABLED=true
|
||||||
|
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_NAME=payment
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_BASE_URL=https://payment.example
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0=payment.example
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_ALLOWED_PORTS_0=443
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_REQUEST_MAX_BODY_BYTES=1048576
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_TLS_PROFILE_ID=payment
|
||||||
|
|
||||||
|
APP_HTTPCLIENT_DYNAMIC_TARGETS_0_NAME=webhook
|
||||||
|
APP_HTTPCLIENT_DYNAMIC_TARGETS_0_ALLOWED_SCHEMES_0=https
|
||||||
|
```
|
||||||
|
|
||||||
|
`docs/httpclient/env-fields.yaml` is the registry of accepted variable names. It is
|
||||||
|
derived from the settings record and held to it in both directions, and the platform refuses to
|
||||||
|
start on an `APP_HTTPCLIENT_` variable that is not in it — so
|
||||||
|
`APP_HTTPCLIENT_CLIENTS_0_TIMEUOT_TOTAL_CALL` fails startup instead of silently leaving the client
|
||||||
|
on its default budget. Unknown keys supplied through a configuration file rather than the
|
||||||
|
environment are refused by strict binding for the same reason.
|
||||||
|
|
||||||
|
Only `APP_HTTPCLIENT_ENABLED` appears in `src/.env` and `docs/registries/env-keys.yaml`. It is the
|
||||||
|
one key with a deployment-independent value; templating an indexed client in `application.yml` would
|
||||||
|
materialise a nameless client in every deployment, which the aggregate validation refuses.
|
||||||
|
|
||||||
|
## `app.httpclient.clients[N]`
|
||||||
|
|
||||||
|
| Property | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `name` | string | — | Required, unique, and distinct from every other name once normalised for the environment |
|
||||||
|
| `mode` | `TRUSTED` \| `DYNAMIC` | `TRUSTED` | A dynamic profile may not carry a default credential |
|
||||||
|
| `base-url` | URI | — | Required for a trusted profile; no userinfo, no query |
|
||||||
|
| `allowed-hosts` | list | empty | Required in production |
|
||||||
|
| `allowed-ports` | list | empty | Compared against the effective port |
|
||||||
|
| `api` | `REST_CLIENT` \| `WEB_CLIENT` | `REST_CLIENT` | Decides blocking or reactive runtime |
|
||||||
|
| `transport` | `APACHE` \| `JDK` \| `REACTOR_NETTY` \| `JETTY` \| `SIMPLE` | `APACHE` | `SIMPLE` is rejected in production |
|
||||||
|
| `protocols` | list | `HTTP_1_1` | The default transport is Apache, whose classic client is HTTP/1.1 only; a profile that wants HTTP/2 declares it together with a transport that can deliver it. `HTTP_3` requires the experimental acknowledgement |
|
||||||
|
| `experimental-acknowledgement` | string | — | Must equal `I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS` |
|
||||||
|
|
||||||
|
### `pool`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `max-total-connections` | `50` | Socket ceiling for the runtime |
|
||||||
|
| `max-connections-per-route` | `25` | Per-upstream ceiling |
|
||||||
|
| `max-pending-acquires` | `100` | Waiting-request memory ceiling |
|
||||||
|
| `pending-acquire-timeout` | `200ms` | Pool or stream wait ceiling |
|
||||||
|
| `max-idle-time` | `30s` | Idle eviction |
|
||||||
|
| `max-life-time` | `5m` | Picks up DNS, load-balancer, and certificate changes |
|
||||||
|
| `validate-after-inactivity` | `5s` | Stale and half-open detection |
|
||||||
|
| `eviction-interval` | `15s` | Background cleanup |
|
||||||
|
| `shutdown-timeout` | `5s` | Drain deadline before forced close |
|
||||||
|
| `requires-route-pool` | `false` | Set when route-scoped limits are mandatory; the JDK transport then refuses the profile |
|
||||||
|
| `requires-bounded-pending-queue` | `false` | Same, for a bounded pending queue |
|
||||||
|
|
||||||
|
### `timeout`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `dns` | `300ms` | Hostname resolution |
|
||||||
|
| `connect` | `500ms` | Socket connect |
|
||||||
|
| `tls-handshake` | `1s` | TLS and ALPN |
|
||||||
|
| `proxy-connect` | `500ms` | Proxy socket or CONNECT |
|
||||||
|
| `request-write-idle` | `1s` | No progress writing the request |
|
||||||
|
| `response-header` | `2s` | Until final response headers |
|
||||||
|
| `read-idle` | `3s` | Between response chunks |
|
||||||
|
| `total-call` | `4s` | The whole logical call, including retry backoff |
|
||||||
|
| `streaming-idle` | `30s` | Silence on a long-lived stream |
|
||||||
|
|
||||||
|
`total-call` must not be shorter than `connect` or `response-header`; the validator emits
|
||||||
|
`INVALID_TIMEOUT_BUDGET` otherwise.
|
||||||
|
|
||||||
|
### `redirect`, `request`, `response`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `redirect.enabled` | `false` | Engine redirect handling is always off; the platform follows hops itself |
|
||||||
|
| `redirect.max-hops` | `0` | Enabling redirects with zero hops is a configuration error |
|
||||||
|
| `redirect.allow-cross-origin` | `false` | When enabled, credentials are stripped on the hop |
|
||||||
|
| `request.max-body-bytes` | `0` | Required in production |
|
||||||
|
| `request.compression` | `false` | |
|
||||||
|
| `response.max-wire-bytes` | `5242880` | Bytes on the wire |
|
||||||
|
| `response.max-decoded-bytes` | `10485760` | Bytes after decoding; hard maximum is 64 MiB |
|
||||||
|
| `response.allowed-content-types` | JSON + problem+json | Empty means "any" |
|
||||||
|
|
||||||
|
### `authentication`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `type` | `NONE` | One of the design §20.1 methods |
|
||||||
|
| `registration-id` | — | Required for OAuth2 |
|
||||||
|
| `scopes` | empty | Part of the token cache key |
|
||||||
|
| `audience` | — | Part of the token cache key |
|
||||||
|
| `header-name` | — | Required for `API_KEY_HEADER`; must be on the allowlist |
|
||||||
|
| `secret-reference` | — | Resolved by the deployment's secret loader, never a literal |
|
||||||
|
|
||||||
|
### `retry`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `policy` | `none` | Named policy for reporting |
|
||||||
|
| `max-attempts` | `1` | Attempts, not retries |
|
||||||
|
| `base-backoff` | `50ms` | |
|
||||||
|
| `max-backoff` | `200ms` | |
|
||||||
|
| `jitter` | `FULL` | `NONE` \| `FULL` \| `DECORRELATED` |
|
||||||
|
| `retry-after` | `HONOR` | `HONOR` \| `IGNORE` \| `CAP` |
|
||||||
|
| `budget` | — | Shared token bucket name |
|
||||||
|
|
||||||
|
### `tls`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `profile-id` | — | Required in production; the only TLS identifier the actuator exposes |
|
||||||
|
| `protocols` | `TLSv1.3, TLSv1.2` | Anything else is rejected |
|
||||||
|
| `hostname-verification` | `true` | Setting it false fails startup |
|
||||||
|
| `trust-all` | `false` | Exists only so the unsafe intent is rejectable; nothing acts on `true` |
|
||||||
|
| `allow-plain-http` | `false` | Plaintext fallback fails startup in production |
|
||||||
|
| `trust-material-reference` | — | Custom CA, resolved by the secret loader |
|
||||||
|
| `key-material-reference` | — | Client certificate for mTLS |
|
||||||
|
|
||||||
|
### `proxy` and `observability`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `proxy.enabled` | `false` | |
|
||||||
|
| `proxy.host` / `proxy.port` / `proxy.type` | — / `0` / `HTTP` | |
|
||||||
|
| `proxy.credential-provider` | — | Proxy authentication is separate from target authentication |
|
||||||
|
| `proxy.connect-timeout` | `500ms` | Recorded as its own metric |
|
||||||
|
| `proxy.import-ambient-no-proxy` | `false` | Ambient `NO_PROXY` never widens a validated profile |
|
||||||
|
| `observability.operation-name-required` | `true` | |
|
||||||
|
| `observability.full-url-recording` | `false` | |
|
||||||
|
| `observability.body-logging` | `false` | |
|
||||||
|
|
||||||
|
## `app.httpclient.dynamic-targets[N]`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | — | Required, unique, and subject to the same normalisation rule as a client name |
|
||||||
|
| `allowed-schemes` | `https` | |
|
||||||
|
| `allowed-ports` | `443` | |
|
||||||
|
| `allowed-host-suffixes` | empty | |
|
||||||
|
| `allowed-hosts` | empty | Empty means "any host that survives address validation" |
|
||||||
|
| `max-redirect-hops` | `0` | Each hop repeats the full validation flow |
|
||||||
|
| `trace-propagation` | `false` | Off by default for dynamic targets |
|
||||||
|
| `blocked-cidrs` | empty | Organisation-defined internal ranges |
|
||||||
|
|
||||||
|
## Startup violation codes
|
||||||
|
|
||||||
|
`TRUSTED_BASE_URL_REQUIRED`, `BASE_URL_USERINFO_FORBIDDEN`, `BASE_URL_QUERY_FORBIDDEN`,
|
||||||
|
`PLAINTEXT_PRODUCTION_TARGET`, `ALLOWED_HOST_MISMATCH`, `ALLOWED_PORT_MISMATCH`,
|
||||||
|
`REDIRECT_POLICY_INVALID`, `REDIRECT_CROSS_ORIGIN_CREDENTIAL_POLICY_REQUIRED`,
|
||||||
|
`INVALID_TIMEOUT_BUDGET`, `RESPONSE_HARD_MAXIMUM_EXCEEDED`, `PRODUCTION_SIMPLE_FACTORY_FORBIDDEN`,
|
||||||
|
`JDK_FINE_GRAINED_POOL_UNSUPPORTED`, `HTTP3_STABLE_FORBIDDEN`,
|
||||||
|
`DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED`, `DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN`,
|
||||||
|
`OAUTH2_REGISTRATION_REQUIRED`, `API_KEY_HEADER_NAME_REQUIRED`, `TRUST_ALL_FORBIDDEN`,
|
||||||
|
`HOSTNAME_VERIFICATION_REQUIRED`, `PLAINTEXT_FALLBACK_FORBIDDEN`, `TLS_PROTOCOL_FORBIDDEN`,
|
||||||
|
`RETRY_BACKOFF_REQUIRED`, `MISSING_PRODUCTION_SETTING`, `DUPLICATE_CLIENT_NAME`,
|
||||||
|
`HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`, `DYNAMIC_BASE_URL_REQUIRED`,
|
||||||
|
`DYNAMIC_TARGET_PROXY_UNSUPPORTED`, `REACTIVE_AUTHENTICATION_UNSUPPORTED`,
|
||||||
|
`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`, `POOL_ROUTE_EXCEEDS_TOTAL`,
|
||||||
|
`TLS_PROTOCOL_SET_REQUIRED`, `REACTIVE_REDIRECT_UNSUPPORTED`,
|
||||||
|
`RETRY_POLICY_CONTRADICTS_ATTEMPTS`, `FULL_URL_RECORDING_FORBIDDEN`, `BODY_LOGGING_FORBIDDEN`,
|
||||||
|
`DNS_TIMEOUT_UNSUPPORTED`, `PROXY_CREDENTIAL_UNSUPPORTED`, `PROXY_AMBIENT_NO_PROXY_UNSUPPORTED`.
|
||||||
|
|
||||||
|
The last three name settings the platform binds but cannot yet honour. Neither the Apache classic
|
||||||
|
client nor the JDK client exposes a DNS-resolution timeout, and no proxy-credential path exists, so
|
||||||
|
a non-default value is refused rather than accepted and ignored. Leaving the defaults alone is
|
||||||
|
unaffected — only a deliberate, unmet request fails.
|
||||||
|
|
||||||
|
Three of these are about a guarantee that used to be silently unmet rather than refused:
|
||||||
|
|
||||||
|
- `HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED` — declaring `protocols: [HTTP_2]` alone states that HTTP/2
|
||||||
|
is required. Only `REACTOR_NETTY` can be configured to offer H2 and nothing else; the JDK client
|
||||||
|
treats it as a preference and negotiates HTTP/1.1, and Apache's classic client is HTTP/1.1 only.
|
||||||
|
- `POOL_ROUTE_EXCEEDS_TOTAL` — a per-route ceiling above the total is incoherent, and on Reactor,
|
||||||
|
where the per-route knob is the only one that exists, it silently becomes the effective limit.
|
||||||
|
- `TLS_PROTOCOL_SET_REQUIRED` — an empty `tls.protocols` used to pass and then let the JVM choose,
|
||||||
|
so emptying the list to "tighten" a profile loosened it.
|
||||||
|
- `REACTIVE_REDIRECT_UNSUPPORTED` — engine redirect following is disabled on every transport and
|
||||||
|
only the blocking stack has a coordinator that follows hops with per-hop re-validation. A
|
||||||
|
`WEB_CLIENT` profile with `redirect.enabled=true` did not follow redirects; the caller received the
|
||||||
|
3xx as an ordinary response. Refused until the reactive coordinator exists.
|
||||||
|
- `RETRY_POLICY_CONTRADICTS_ATTEMPTS` — `retry.policy` was read by nothing on the execution path, so
|
||||||
|
the actuator could report `none` for a profile retrying three times. The two settings must now
|
||||||
|
agree: `policy: none` requires `max-attempts: 1`, and any other policy requires more than one.
|
||||||
|
- `FULL_URL_RECORDING_FORBIDDEN` / `BODY_LOGGING_FORBIDDEN` — both settings were bindable and inert.
|
||||||
|
Recording an expanded URL puts path identifiers and query strings into unbounded metric tags;
|
||||||
|
recording bodies puts someone else's data into logs. Representable so the intent is rejectable,
|
||||||
|
refused under a production profile.
|
||||||
|
|
||||||
|
`DYNAMIC_TARGET_PROXY_UNSUPPORTED` is worth spelling out: a forward proxy resolves the hostname on
|
||||||
|
its own side, so the addresses this platform validated and pinned are not the addresses the
|
||||||
|
connection reaches. The SSRF defence would be present, correct, and bypassed — so the combination is
|
||||||
|
refused rather than served with a guarantee it cannot keep.
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# HTTP Client platform — Java field path to environment variable template.
|
||||||
|
#
|
||||||
|
# The SSOT is HttpClientPlatformSettings. HttpClientEnvironmentKeys derives this list from the
|
||||||
|
# record tree at runtime, HttpClientPlatformEnvManifestTest fails when the two disagree in either
|
||||||
|
# direction, and the platform refuses to start on an APP_HTTPCLIENT_ variable that is not here. So a
|
||||||
|
# field added with no entry, an entry whose field was renamed, and a misspelled variable in a
|
||||||
|
# deployment are all failures rather than silence.
|
||||||
|
#
|
||||||
|
# `N` and `M` are list indices, not literals: `N` for the outermost list, `M` for a list inside it.
|
||||||
|
# `app.httpclient.clients[N].base-url` is set as APP_HTTPCLIENT_CLIENTS_0_BASE_URL for the first
|
||||||
|
# client, and `clients[N].allowed-hosts[M]` as APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0.
|
||||||
|
#
|
||||||
|
# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in
|
||||||
|
# src/.env: it is the only key with a deployment-independent value, and it is the only one the
|
||||||
|
# three-way verifyEnvKeys gate can express. Everything below is per deployment and is set directly
|
||||||
|
# in the environment — templating an indexed client in application.yml would materialise a nameless
|
||||||
|
# client in every deployment, which the settings' aggregate validation refuses.
|
||||||
|
#
|
||||||
|
# This file lives beside the HTTP Client documentation rather than in docs/registries, which is a
|
||||||
|
# fail-closed catalog of exactly eight contract registries with a fixed row schema
|
||||||
|
# (owner_branch/compatibility_impact/required_test per row). A field-to-variable mapping does not
|
||||||
|
# have that shape, and admitting it would have meant loosening a gate rather than satisfying one.
|
||||||
|
#
|
||||||
|
# Secrets are referenced, never carried: authentication.secret-reference, tls.*-material-reference
|
||||||
|
# and proxy.credential-provider name material that a secret backend resolves. Putting the material
|
||||||
|
# itself in one of these variables defeats the indirection they exist for.
|
||||||
|
fields:
|
||||||
|
- field: enabled
|
||||||
|
env: APP_HTTPCLIENT_ENABLED
|
||||||
|
- field: clients[N].name
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_NAME
|
||||||
|
- field: clients[N].mode
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_MODE
|
||||||
|
- field: clients[N].base-url
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_BASE_URL
|
||||||
|
- field: clients[N].allowed-hosts[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_ALLOWED_HOSTS_M
|
||||||
|
- field: clients[N].allowed-ports[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_ALLOWED_PORTS_M
|
||||||
|
- field: clients[N].api
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_API
|
||||||
|
- field: clients[N].transport
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TRANSPORT
|
||||||
|
- field: clients[N].protocols[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROTOCOLS_M
|
||||||
|
- field: clients[N].pool.max-total-connections
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_TOTAL_CONNECTIONS
|
||||||
|
- field: clients[N].pool.max-connections-per-route
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_CONNECTIONS_PER_ROUTE
|
||||||
|
- field: clients[N].pool.max-pending-acquires
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_PENDING_ACQUIRES
|
||||||
|
- field: clients[N].pool.pending-acquire-timeout
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_PENDING_ACQUIRE_TIMEOUT
|
||||||
|
- field: clients[N].pool.max-idle-time
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_IDLE_TIME
|
||||||
|
- field: clients[N].pool.max-life-time
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_LIFE_TIME
|
||||||
|
- field: clients[N].pool.validate-after-inactivity
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_VALIDATE_AFTER_INACTIVITY
|
||||||
|
- field: clients[N].pool.eviction-interval
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_EVICTION_INTERVAL
|
||||||
|
- field: clients[N].pool.shutdown-timeout
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_SHUTDOWN_TIMEOUT
|
||||||
|
- field: clients[N].pool.requires-route-pool
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_REQUIRES_ROUTE_POOL
|
||||||
|
- field: clients[N].pool.requires-bounded-pending-queue
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_REQUIRES_BOUNDED_PENDING_QUEUE
|
||||||
|
- field: clients[N].timeout.dns
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_DNS
|
||||||
|
- field: clients[N].timeout.connect
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_CONNECT
|
||||||
|
- field: clients[N].timeout.tls-handshake
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_TLS_HANDSHAKE
|
||||||
|
- field: clients[N].timeout.proxy-connect
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_PROXY_CONNECT
|
||||||
|
- field: clients[N].timeout.request-write-idle
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_REQUEST_WRITE_IDLE
|
||||||
|
- field: clients[N].timeout.response-header
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_RESPONSE_HEADER
|
||||||
|
- field: clients[N].timeout.read-idle
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_READ_IDLE
|
||||||
|
- field: clients[N].timeout.total-call
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_TOTAL_CALL
|
||||||
|
- field: clients[N].timeout.streaming-idle
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_STREAMING_IDLE
|
||||||
|
- field: clients[N].redirect.enabled
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_ENABLED
|
||||||
|
- field: clients[N].redirect.max-hops
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_MAX_HOPS
|
||||||
|
- field: clients[N].redirect.allow-cross-origin
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_ALLOW_CROSS_ORIGIN
|
||||||
|
- field: clients[N].request.max-body-bytes
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_REQUEST_MAX_BODY_BYTES
|
||||||
|
- field: clients[N].request.compression
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_REQUEST_COMPRESSION
|
||||||
|
- field: clients[N].response.max-wire-bytes
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_MAX_WIRE_BYTES
|
||||||
|
- field: clients[N].response.max-decoded-bytes
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_MAX_DECODED_BYTES
|
||||||
|
- field: clients[N].response.allowed-content-types[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_ALLOWED_CONTENT_TYPES_M
|
||||||
|
- field: clients[N].authentication.type
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_TYPE
|
||||||
|
- field: clients[N].authentication.registration-id
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_REGISTRATION_ID
|
||||||
|
- field: clients[N].authentication.scopes[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_SCOPES_M
|
||||||
|
- field: clients[N].authentication.audience
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_AUDIENCE
|
||||||
|
- field: clients[N].authentication.header-name
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_HEADER_NAME
|
||||||
|
- field: clients[N].authentication.secret-reference
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_SECRET_REFERENCE
|
||||||
|
- field: clients[N].retry.policy
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_POLICY
|
||||||
|
- field: clients[N].retry.max-attempts
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_MAX_ATTEMPTS
|
||||||
|
- field: clients[N].retry.base-backoff
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_BASE_BACKOFF
|
||||||
|
- field: clients[N].retry.max-backoff
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_MAX_BACKOFF
|
||||||
|
- field: clients[N].retry.jitter
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_JITTER
|
||||||
|
- field: clients[N].retry.retry-after
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_RETRY_AFTER
|
||||||
|
- field: clients[N].retry.budget
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_BUDGET
|
||||||
|
- field: clients[N].observability.operation-name-required
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_OPERATION_NAME_REQUIRED
|
||||||
|
- field: clients[N].observability.full-url-recording
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_FULL_URL_RECORDING
|
||||||
|
- field: clients[N].observability.body-logging
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_BODY_LOGGING
|
||||||
|
- field: clients[N].tls.profile-id
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_PROFILE_ID
|
||||||
|
- field: clients[N].tls.protocols[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_PROTOCOLS_M
|
||||||
|
- field: clients[N].tls.hostname-verification
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_HOSTNAME_VERIFICATION
|
||||||
|
- field: clients[N].tls.trust-all
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_TRUST_ALL
|
||||||
|
- field: clients[N].tls.allow-plain-http
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_ALLOW_PLAIN_HTTP
|
||||||
|
- field: clients[N].tls.trust-material-reference
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_TRUST_MATERIAL_REFERENCE
|
||||||
|
- field: clients[N].tls.key-material-reference
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_KEY_MATERIAL_REFERENCE
|
||||||
|
- field: clients[N].proxy.enabled
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_ENABLED
|
||||||
|
- field: clients[N].proxy.host
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_HOST
|
||||||
|
- field: clients[N].proxy.port
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_PORT
|
||||||
|
- field: clients[N].proxy.type
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_TYPE
|
||||||
|
- field: clients[N].proxy.credential-provider
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_CREDENTIAL_PROVIDER
|
||||||
|
- field: clients[N].proxy.connect-timeout
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_CONNECT_TIMEOUT
|
||||||
|
- field: clients[N].proxy.import-ambient-no-proxy
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_IMPORT_AMBIENT_NO_PROXY
|
||||||
|
- field: clients[N].experimental-acknowledgement
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_EXPERIMENTAL_ACKNOWLEDGEMENT
|
||||||
|
- field: dynamic-targets[N].name
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_NAME
|
||||||
|
- field: dynamic-targets[N].allowed-schemes[M]
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_SCHEMES_M
|
||||||
|
- field: dynamic-targets[N].allowed-ports[M]
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_PORTS_M
|
||||||
|
- field: dynamic-targets[N].allowed-host-suffixes[M]
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_HOST_SUFFIXES_M
|
||||||
|
- field: dynamic-targets[N].allowed-hosts[M]
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_HOSTS_M
|
||||||
|
- field: dynamic-targets[N].max-redirect-hops
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_MAX_REDIRECT_HOPS
|
||||||
|
- field: dynamic-targets[N].trace-propagation
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_TRACE_PROPAGATION
|
||||||
|
- field: dynamic-targets[N].blocked-cidrs[M]
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_BLOCKED_CIDRS_M
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Migrating from `RestTemplate`
|
||||||
|
|
||||||
|
`RestTemplate` is permitted only inside `…httpclient.migration`; `RestTemplateBoundaryTest` enforces
|
||||||
|
that. New retry, Dynamic Target, and HTTP/3 capabilities are deliberately unreachable from the
|
||||||
|
migration path — a caller that wants them moves to a Named Client Profile.
|
||||||
|
|
||||||
|
## 1. Audit before changing anything
|
||||||
|
|
||||||
|
```java
|
||||||
|
RestTemplateInventory inventory = new RestTemplateInventoryScanner().scan(existingTemplate);
|
||||||
|
```
|
||||||
|
|
||||||
|
The inventory reports the request factory, message converters, interceptors, error handler, and URI
|
||||||
|
template handler, plus findings:
|
||||||
|
|
||||||
|
| Code | Severity | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `SIMPLE_REQUEST_FACTORY` | blocking | no connection pool; unsupported in production |
|
||||||
|
| `NO_MESSAGE_CONVERTERS` | blocking | the template cannot encode or decode a body |
|
||||||
|
| `NO_INTERCEPTORS` | warning | confirm where correlation and timeouts are applied |
|
||||||
|
| `TIMEOUTS_NOT_INTROSPECTABLE` | informational | declare timeouts explicitly on the target profile |
|
||||||
|
|
||||||
|
## 2. Bridge without changing behaviour
|
||||||
|
|
||||||
|
```java
|
||||||
|
RestClient client = new RestTemplateToRestClientAdapter().adaptChecked(existingTemplate);
|
||||||
|
```
|
||||||
|
|
||||||
|
`adaptChecked` refuses to migrate a template with a blocking finding. The bridge carries the
|
||||||
|
existing converters, interceptors, error handler, and URI handler across, so this step changes the
|
||||||
|
API and nothing else.
|
||||||
|
|
||||||
|
## 3. Move to a Named Client Profile
|
||||||
|
|
||||||
|
Turn the platform on with `APP_HTTPCLIENT_ENABLED=true` — it ships off, and while it is off none of
|
||||||
|
the settings below are bound — then declare the upstream as `app.httpclient.clients[N]` with its
|
||||||
|
`name` and an explicit base URL, transport, timeouts, pool, body limits, authentication, retry
|
||||||
|
policy, redirect policy, and TLS profile. Startup validation will tell you exactly which of those is
|
||||||
|
missing. See `docs/httpclient/configuration-reference.md` for the environment form.
|
||||||
|
|
||||||
|
## 4. Move to a typed client
|
||||||
|
|
||||||
|
```java
|
||||||
|
@HttpClientProfile("payment")
|
||||||
|
@HttpExchange("/payments")
|
||||||
|
public interface PaymentClient {
|
||||||
|
|
||||||
|
@PostExchange
|
||||||
|
@HttpOperationPolicy(
|
||||||
|
name = "create-payment",
|
||||||
|
idempotency = OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED,
|
||||||
|
retryPolicy = "payment-write")
|
||||||
|
PaymentResponse create(
|
||||||
|
@RequestHeader("Idempotency-Key") String idempotencyKey, @RequestBody PaymentRequest request);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The interface fails startup validation unless it declares a profile, gives every method a stable
|
||||||
|
operation name and an explicit idempotency, supplies a key parameter when the operation requires
|
||||||
|
one, keeps a single execution model, and does not enable retry on a non-idempotent write.
|
||||||
|
|
||||||
|
## 5. Retire the template
|
||||||
|
|
||||||
|
Once no production package references `RestTemplate`, `RestTemplateBoundaryTest` keeps it that way.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# HTTP Client Platform — Operations Runbook
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
| Metric | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `http.client.requests` | Physical attempt timer (Spring standard name, kept deliberately) |
|
||||||
|
| `http.client.logical.calls` | User-visible logical call timer |
|
||||||
|
| `http.client.attempts` | Attempt counter |
|
||||||
|
| `http.client.retry.count` | Retries by reason |
|
||||||
|
| `http.client.retry.exhausted` | Retry budget exhausted |
|
||||||
|
| `http.client.ambiguous` | Ambiguous outcomes |
|
||||||
|
| `http.client.timeout` | Timeouts by stage |
|
||||||
|
| `http.client.request.bytes` | Request wire bytes |
|
||||||
|
| `http.client.response.bytes` | Response bytes |
|
||||||
|
| `http.client.active` | In-flight attempts |
|
||||||
|
| `http.client.pool.connections` | Leased and available connections |
|
||||||
|
| `http.client.pool.pending` | Pool waiters |
|
||||||
|
| `http.client.pool.acquire.duration` | Pool wait time |
|
||||||
|
| `http.client.dns.duration` | DNS time |
|
||||||
|
| `http.client.connect.duration` | Connect time |
|
||||||
|
| `http.client.tls.duration` | TLS time |
|
||||||
|
| `http.client.circuit.state` | Circuit state |
|
||||||
|
| `http.client.bulkhead.rejected` | Bulkhead rejections |
|
||||||
|
| `http.client.rate_limit.rejected` | Local rate-limit rejections |
|
||||||
|
| `http.client.oauth.refresh` | Token refresh outcomes |
|
||||||
|
| `http.client.ssrf.rejected` | Dynamic target rejections |
|
||||||
|
|
||||||
|
`http.client.requests` counts attempts and `http.client.logical.calls` counts user calls. When they
|
||||||
|
diverge, retries are absorbing failures — which is the first thing to look at during an incident.
|
||||||
|
|
||||||
|
## Reading an incident
|
||||||
|
|
||||||
|
| Symptom | Likely cause | Where to look |
|
||||||
|
|---|---|---|
|
||||||
|
| logical calls fine, attempts spiking | upstream degraded, retries absorbing it | `http.client.retry.count` by reason |
|
||||||
|
| `http.client.ambiguous` non-zero | non-idempotent writes reaching `SENT_NO_RESPONSE` | reconcile with the upstream; consider an idempotency key |
|
||||||
|
| pool pending climbing | pool too small or upstream slow | `http.client.pool.acquire.duration`, `pool.connections` |
|
||||||
|
| circuit open | sustained upstream failure | `http.client.circuit.state`; local rejections do not open it |
|
||||||
|
| `http.client.ssrf.rejected` non-zero | a caller is submitting internal URLs | Dynamic Target policy and audit trail |
|
||||||
|
|
||||||
|
## Actuator
|
||||||
|
|
||||||
|
`GET /actuator/httpclients` reports profile name, runtime generation, state, transport, API,
|
||||||
|
protocols, active leases, pool ceiling, credential type, TLS profile id, redirect flag, retry policy,
|
||||||
|
and capability warnings. Base URL, credentials, trust store paths, and resolved IPs are deliberately
|
||||||
|
absent: an actuator endpoint is reachable by more people than a secret store is.
|
||||||
|
|
||||||
|
## Rotation
|
||||||
|
|
||||||
|
Certificates and secrets rotate by building a new runtime generation and swapping the registry
|
||||||
|
pointer, never by mutating a live client. A connection pool holds sockets established under the
|
||||||
|
previous identity, so replacing material without replacing the pool leaves live connections
|
||||||
|
authenticated by a certificate that is meant to be gone.
|
||||||
|
|
||||||
|
```text
|
||||||
|
build new generation → validate → atomic swap → new calls use it
|
||||||
|
old generation → DRAINING → in-flight calls finish → no new retries → forced close at the drain deadline
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shutdown
|
||||||
|
|
||||||
|
```text
|
||||||
|
RUNNING → DRAINING
|
||||||
|
new logical calls refused or routed to the new generation
|
||||||
|
in-flight attempts complete
|
||||||
|
new retries refused
|
||||||
|
shutdown timeout
|
||||||
|
remaining calls cancelled
|
||||||
|
pool closed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Retry ownership
|
||||||
|
|
||||||
|
Exactly one of the application client, an external SDK, or the service mesh may own retries.
|
||||||
|
Two owners multiply traffic during an incident. Record the owner per upstream and check it whenever
|
||||||
|
a mesh retry policy changes.
|
||||||
|
|
||||||
|
## Error model
|
||||||
|
|
||||||
|
Every outbound failure is one of these stable types. The type is derived from the classified failure
|
||||||
|
category, not from whatever the engine happened to throw, so it means the same thing on Apache, JDK,
|
||||||
|
and Reactor Netty. Each carries `HttpFailureMetadata`: client, operation, method, URI **template**,
|
||||||
|
evidence, replayability, stage, retryability, attempt, elapsed, remaining deadline, status, trace id
|
||||||
|
— and nothing else.
|
||||||
|
|
||||||
|
| Exception | Raised when | Retryable |
|
||||||
|
|---|---|---|
|
||||||
|
| `HttpConfigurationException` | profile, operation, or capability configuration is invalid | never |
|
||||||
|
| `HttpTargetRejectedException` | target URI, host, port, header, or address policy refused the request | never |
|
||||||
|
| `HttpDnsException` | hostname resolution failed or timed out | yes, inside budget |
|
||||||
|
| `HttpPoolAcquireTimeoutException` | no connection or stream within the pending-acquire budget | yes, inside budget |
|
||||||
|
| `HttpConnectException` | socket connect failed | yes, inside budget |
|
||||||
|
| `HttpProxyException` | proxy connect, CONNECT tunnel, or proxy auth failed | yes, inside budget |
|
||||||
|
| `HttpTlsException` | TLS handshake failed | only a transient handshake timeout |
|
||||||
|
| `HttpRequestWriteException` | request headers or body could not be fully written | only when safely idempotent |
|
||||||
|
| `HttpResponseTimeoutException` | final headers or a body chunk did not arrive in time | only when safely idempotent |
|
||||||
|
| `HttpResponseTruncatedException` | the response ended before the body was complete | only when safely idempotent and undelivered |
|
||||||
|
| `HttpRemoteErrorException` | non-success status without a problem document | per the status rules |
|
||||||
|
| `HttpProblemDetailException` | non-success status with a bounded RFC 9457 document | per the status rules |
|
||||||
|
| `HttpRedirectRejectedException` | a hop violated hop count, origin, method, or replay policy | never |
|
||||||
|
| `HttpAuthenticationException` | credential materialization or refresh failed | never |
|
||||||
|
| `HttpSerializationException` | request encoding or response decoding failed | never |
|
||||||
|
| `HttpResponseTooLargeException` | wire or decoded bytes exceeded the profile limit | never |
|
||||||
|
| `HttpDeadlineExceededException` | the effective deadline was reached | never |
|
||||||
|
| `HttpCircuitOpenException` | the upstream circuit is open | never |
|
||||||
|
| `HttpBulkheadRejectedException` | no attempt or logical admission permit was available | never |
|
||||||
|
| `HttpRateLimitRejectedException` | the local attempt rate limit or retry budget rejected the attempt | never |
|
||||||
|
| `HttpAmbiguousExecutionException` | a non-idempotent request was sent and the outcome is unknown | never — reconcile instead |
|
||||||
|
|
||||||
|
## Traces
|
||||||
|
|
||||||
|
```text
|
||||||
|
http.client.operation logical internal span
|
||||||
|
└─ http.client.request attempt 1 CLIENT span
|
||||||
|
└─ http.client.request attempt 2 CLIENT span
|
||||||
|
```
|
||||||
|
|
||||||
|
W3C Trace Context is propagated with a Baggage allowlist. Dynamic Targets do not propagate trace
|
||||||
|
context by default. Retry reason and evidence are recorded as span events; credentials and remote
|
||||||
|
error bodies are never recorded as attributes.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# HTTP Client Platform — Performance Baseline
|
||||||
|
|
||||||
|
The certification lane asserts **resource bounds**, not throughput targets. Its purpose is to prove
|
||||||
|
that a failing upstream, a large body, or a rotation cannot consume unbounded memory, connections,
|
||||||
|
threads, or upstream traffic. Nothing here becomes a runtime adaptive default: every bound comes
|
||||||
|
from an explicit profile setting.
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# structural bounds only (default; still executes every test)
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --console=plain
|
||||||
|
|
||||||
|
# full certification, including machine-dependent bounds
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest \
|
||||||
|
-Pperformance.assertions.enabled=true --console=plain
|
||||||
|
|
||||||
|
# JMH benchmarks
|
||||||
|
./gradlew :adapter:outbound:httpclient:jmh --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Machine-dependent assertions are reported as explicitly skipped when the flag is absent — the lane
|
||||||
|
never silently degrades into a pass.
|
||||||
|
|
||||||
|
## Certified bounds
|
||||||
|
|
||||||
|
| Test | Bound | Kind |
|
||||||
|
|---|---|---|
|
||||||
|
| `RetryStormBudgetTest` | 10 000 logical calls against a failing upstream produce at most 11 000 physical attempts at a 10 % budget | structural |
|
||||||
|
| `LargeBodyResourceTest` | a 32 MiB streaming download consumes every byte without buffering the payload on the heap | structural + machine-dependent heap bound |
|
||||||
|
| `PoolSaturationPerformanceTest` | 24 concurrent calls against a 4-connection pool all reach a terminal outcome; none hang | structural |
|
||||||
|
| `Http2StreamSaturationTest` | 32 concurrent reactive streams share a 2-connection pool and complete | structural |
|
||||||
|
| `OAuthRefreshContentionTest` | 100 genuinely concurrent callers produce exactly one token request | structural |
|
||||||
|
| `RuntimeRotationDrainTest` | 50 rotations close all 50 retired generations and leave no drain thread | structural |
|
||||||
|
|
||||||
|
## Recording a baseline
|
||||||
|
|
||||||
|
When certifying a deployment, record alongside the numbers: the exact command, the commit, hardware,
|
||||||
|
JVM flags, the profile YAML under test, p50/p95/p99/max, peak heap, peak direct memory, thread count,
|
||||||
|
connection count, physical attempt count, and error count. A latency figure without its profile and
|
||||||
|
hardware is not a baseline; it is an anecdote.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Command | _fill in at certification time_ |
|
||||||
|
| Commit | _fill in_ |
|
||||||
|
| Hardware / JVM | _fill in_ |
|
||||||
|
| Profile under test | _fill in_ |
|
||||||
|
| p50 / p95 / p99 / max | _fill in_ |
|
||||||
|
| Peak heap / direct memory | _fill in_ |
|
||||||
|
| Threads / connections | _fill in_ |
|
||||||
|
| Physical attempts / errors | _fill in_ |
|
||||||
|
|
||||||
|
The table is intentionally left unfilled in the repository: publishing numbers measured on a build
|
||||||
|
agent as if they were a certified baseline would be worse than having none.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# HTTP Client Platform — Release Checklist
|
||||||
|
|
||||||
|
A release is complete when each item below is demonstrated by a command, not by review.
|
||||||
|
|
||||||
|
## Gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :adapter:outbound:httpclient:test --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:spring62CompatibilityTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --console=plain # needs Docker
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest \
|
||||||
|
-Pperformance.assertions.enabled=true --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
python3 ../scripts/verify-httpclient-docs.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Completion criteria (design §33)
|
||||||
|
|
||||||
|
- [ ] Typed clients are the default entry point; H2 and H3 are separately authorised.
|
||||||
|
- [ ] H1–H4 cannot bypass timeout, host, TLS, auth, size, or observation policy.
|
||||||
|
- [ ] Apache, JDK, and Reactor produce identical result and exception metadata.
|
||||||
|
- [ ] Pool, DNS, connect, TLS, and retry backoff all fit inside the effective deadline.
|
||||||
|
- [ ] Every extra attempt is explained by idempotency, replayability, evidence, deadline, and budget.
|
||||||
|
- [ ] Non-idempotent `SENT_NO_RESPONSE` surfaces as `HttpAmbiguousExecutionException`.
|
||||||
|
- [ ] Pool and buffers are reclaimed after unread bodies, decode errors, cancels, and size rejections.
|
||||||
|
- [ ] OAuth2 refresh is single-flight and 401 replay happens at most once.
|
||||||
|
- [ ] Trust-all and hostname-verification bypass fail at startup.
|
||||||
|
- [ ] Canonicalisation, DNS/IP validation, redirect revalidation, and egress control all pass.
|
||||||
|
- [ ] No transparent retry occurs after the first delivered byte.
|
||||||
|
- [ ] No platform code blocks a Reactor event loop, proven by a BlockHound self-check.
|
||||||
|
- [ ] The negotiated wire protocol matches what the support matrix claims per transport.
|
||||||
|
- [ ] Logical calls and attempts are separate metrics with no forbidden label.
|
||||||
|
- [ ] DNS, pool, TLS, reset, partial response, and HTTP/2 GOAWAY are reproducible.
|
||||||
|
- [ ] Thread, heap, direct memory, pool, and retry budget bounds hold.
|
||||||
|
- [ ] The support matrix, configuration reference, security guide, runbook, and migration guide match the code.
|
||||||
|
|
||||||
|
## Experimental
|
||||||
|
|
||||||
|
Jetty HTTP/3 stays Experimental until `Http3CapabilityReport` reports QUIC and TLS 1.3 and the
|
||||||
|
contract subset it declares passes in a dedicated environment. It is never auto-configured by the
|
||||||
|
Stable starter.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# HTTP Client Platform — Repository Adaptation Contract
|
||||||
|
|
||||||
|
**Design source:** `httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
|
||||||
|
**Plan source:** `httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
|
||||||
|
|
||||||
|
The design package states its own adaptation rule:
|
||||||
|
|
||||||
|
> 실제 Backend Skeleton 저장소가 제공되지 않았으므로 package 경로와 Gradle 구조는 설계서의 명시적
|
||||||
|
> 구현 가정이다. 구현 전 저장소의 기존 convention과 root package에 맞춰 경로만 조정하고 공개 계약과
|
||||||
|
> 정책 의미론은 유지한다.
|
||||||
|
|
||||||
|
This file is the single record of *how* the design's assumed layout was mapped onto this repository.
|
||||||
|
Only paths, build DSL, and composition-root ownership changed. Public contracts, policy order, and
|
||||||
|
error semantics are implemented exactly as specified.
|
||||||
|
|
||||||
|
## 1. Why the module layout differs
|
||||||
|
|
||||||
|
The design assumes a greenfield library with 19 Gradle projects under `modules/httpclient/`.
|
||||||
|
This repository is a Clean Architecture template whose **fail-closed registry**
|
||||||
|
(`src/config/architecture/modules.json`, enforced by `src/settings.gradle` and
|
||||||
|
`verifyCleanArchitectureDependencies`) declares **exactly 19 leaf identities**. Creating 19 more
|
||||||
|
Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
|
||||||
|
|
||||||
|
Therefore the design's 19 library modules become **package boundaries inside the registered leaf**
|
||||||
|
`:adapter:outbound:httpclient`, with two exceptions driven by this repository's own rules:
|
||||||
|
|
||||||
|
| Design module | Repository home | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `httpclient-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`) | This repository's composition root owns wiring and canonical activation; an adapter leaf must not auto-configure itself. |
|
||||||
|
| `httpclient-testkit` | `:adapter:outbound:httpclient` `src/testkit/java/**/testkit` | The design forbids production modules depending on the testkit; a source set whose dependencies are declared only on the test configurations gives the same guarantee without a new Gradle project. It is its own source set rather than part of `test` because three lanes consume it — `test`, `httpClientPerformanceTest` and `jmh` — and reaching into `sourceSets.test.output` from `jmh` compiled under Gradle but could not be modelled by an IDE, which classifies a source set as test source only when a `Test` task runs its output and forbids main source from reading test source. `PlatformClasses` excludes the source set's output so the boundary rules keep meaning production classes. |
|
||||||
|
|
||||||
|
The package boundary is enforced by ArchUnit rules (`PublicApiArchitectureTest`,
|
||||||
|
`HttpClientModuleBoundaryTest`) that reproduce the design's module dependency table.
|
||||||
|
|
||||||
|
## 2. Package mapping
|
||||||
|
|
||||||
|
Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbound.httpclient`.
|
||||||
|
|
||||||
|
| Design module | Design package | Repository package |
|
||||||
|
|---|---|---|
|
||||||
|
| `httpclient-core-api` | `…httpclient.api` (+ `.body`, `.error`, `.operation`, `.result`) | `dev.caskeleton.adapter.outbound.httpclient.api` (+ same subpackages) |
|
||||||
|
| `httpclient-profile` | `…httpclient.profile` | `…outbound.httpclient.profile` |
|
||||||
|
| `httpclient-transport-spi` | `…httpclient.transport` | `…outbound.httpclient.transport` |
|
||||||
|
| `httpclient-transport-apache` | `…httpclient.apache` | `…outbound.httpclient.apache` |
|
||||||
|
| `httpclient-transport-jdk` | `…httpclient.jdk` | `…outbound.httpclient.jdk` |
|
||||||
|
| `httpclient-restclient` | `…httpclient.restclient` | `…outbound.httpclient.restclient` |
|
||||||
|
| `httpclient-resilience` | `…httpclient.resilience` | `…outbound.httpclient.resilience` |
|
||||||
|
| `httpclient-auth` | `…httpclient.auth` | `…outbound.httpclient.auth` |
|
||||||
|
| `httpclient-security` | `…httpclient.security` | `…outbound.httpclient.security` |
|
||||||
|
| `httpclient-observability` | `…httpclient.observation` | `…outbound.httpclient.observation` |
|
||||||
|
| `httpclient-transport-reactor-netty` | `…httpclient.reactor` | `…outbound.httpclient.reactor` |
|
||||||
|
| `httpclient-webclient` | `…httpclient.webclient` | `…outbound.httpclient.webclient` |
|
||||||
|
| `httpclient-service-client` | `…httpclient.service` | `…outbound.httpclient.service` |
|
||||||
|
| `httpclient-dynamic-target` | `…httpclient.dynamic` | `…outbound.httpclient.dynamic` |
|
||||||
|
| `httpclient-resttemplate-migration` | `…httpclient.migration` | `…outbound.httpclient.migration` |
|
||||||
|
| `httpclient-spring7-service-groups` | `…httpclient.spring7` | `…outbound.httpclient.spring7` |
|
||||||
|
| `httpclient-jetty-http3-experimental` | `…httpclient.http3` | `…outbound.httpclient.http3` |
|
||||||
|
| `httpclient-spring-boot-starter` | `…httpclient.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.httpclient` |
|
||||||
|
| `httpclient-testkit` | `…httpclient.testkit` | `…outbound.httpclient.testkit` (`testkit` source set) |
|
||||||
|
|
||||||
|
## 3. Other deliberate substitutions
|
||||||
|
|
||||||
|
| Design assumption | Repository reality | Adaptation |
|
||||||
|
|---|---|---|
|
||||||
|
| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. |
|
||||||
|
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
|
||||||
|
| `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. |
|
||||||
|
| Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. |
|
||||||
|
| Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar |
|
||||||
|
| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||||
|
| `docs/httpclient/**`, `.github/workflows/httpclient-*.yml`, `scripts/verify-httpclient-docs.py` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
|
||||||
|
|
||||||
|
## 4. What is unchanged from the design
|
||||||
|
|
||||||
|
- H1 / H2 / H3 / H4 exposure rules and the forbidden native-engine signatures.
|
||||||
|
- `ExecutionEvidence`, `BodyReplayability`, `OperationIdempotency`, `AttemptStage`, `FailureCategory`.
|
||||||
|
- `HttpOperation`, `HttpCallResult`, `BodySource`, `ResponseType`, `BlockingStreamingResponse`.
|
||||||
|
- The complete stable exception hierarchy and `HttpFailureMetadata` redaction rules.
|
||||||
|
- Named Client Profile schema, startup validation codes, and operation override direction.
|
||||||
|
- Effective deadline formula, attempt budget, and streaming setup/idle split.
|
||||||
|
- Retry eligibility inputs, the ordered decision table, retry budget, and backoff rules.
|
||||||
|
- Circuit → Rate Limiter → Bulkhead attempt order and logical admission placement.
|
||||||
|
- OAuth2 cache key, single-flight refresh, and the 401 replay-at-most-once rule.
|
||||||
|
- TLS allow/forbid lists and permanent-failure classification.
|
||||||
|
- Dynamic Target canonicalization → all-answer DNS validation → pinning → redirect revalidation.
|
||||||
|
- Low-cardinality tag allowlist, forbidden labels, trace and logging rules.
|
||||||
|
- Runtime generation swap and drain semantics.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Retry and Ambiguity
|
||||||
|
|
||||||
|
The platform never decides a retry from the HTTP method alone (design D-09). A second attempt
|
||||||
|
happens only when idempotency, body replayability, execution evidence, deadline, and retry budget
|
||||||
|
all permit it.
|
||||||
|
|
||||||
|
## Execution evidence
|
||||||
|
|
||||||
|
| Evidence | Meaning | Typical cause |
|
||||||
|
|---|---|---|
|
||||||
|
| `NOT_SENT` | Proven that the server never received the request | profile rejection, pool timeout, DNS failure, connect failure, pre-request TLS failure, HTTP/2 `REFUSED_STREAM` |
|
||||||
|
| `SENT_NO_RESPONSE` | Some or all of the request was written, no final header arrived | partial write, response-header timeout, connection reset |
|
||||||
|
| `RESPONSE_RECEIVED` | Final headers arrived, whatever the status | 2xx, 4xx, 5xx, redirect |
|
||||||
|
| `PARTIAL_RESPONSE` | Headers and part of the body arrived | reset during decode, interrupted stream |
|
||||||
|
|
||||||
|
`NOT_SENT` is only produced by a stage failure that proves it. A generic engine I/O error is never
|
||||||
|
upgraded to `NOT_SENT`, because that is exactly how a timeout becomes a duplicate payment.
|
||||||
|
|
||||||
|
## Body replayability
|
||||||
|
|
||||||
|
| Body | Replayability |
|
||||||
|
|---|---|
|
||||||
|
| immutable `byte[]` | `REPLAYABLE` |
|
||||||
|
| DTO plus a deterministic codec | `REPLAYABLE` |
|
||||||
|
| reopenable file or resource supplier | `REOPENABLE` |
|
||||||
|
| a single `InputStream` instance | `ONE_SHOT` |
|
||||||
|
| publisher factory | as declared |
|
||||||
|
| publisher instance | `ONE_SHOT` |
|
||||||
|
| multipart | the weakest part |
|
||||||
|
|
||||||
|
## Decision order
|
||||||
|
|
||||||
|
`DefaultRetryEligibilityEngine` evaluates in this order, and a later rule can never re-enable
|
||||||
|
something an earlier one forbade:
|
||||||
|
|
||||||
|
1. attempts exhausted → `RetryDenied.maxAttempts()`
|
||||||
|
2. retry budget empty → `RetryDenied.budgetExhausted()`
|
||||||
|
3. body not replayable → `RetryDenied.bodyNotReplayable()`
|
||||||
|
4. first byte already delivered → `RetryDenied.responseAlreadyDelivered()`
|
||||||
|
5. runtime draining → `RetryDenied.runtimeDraining()`
|
||||||
|
6. remaining deadline below the minimum attempt budget → `RetryDenied.deadline()`
|
||||||
|
7. permanent failure category → `RetryDenied.permanentFailure(...)`
|
||||||
|
8. `SENT_NO_RESPONSE` on an operation that is not safely idempotent → `AmbiguousFailure`
|
||||||
|
9. status- and failure-specific rules
|
||||||
|
|
||||||
|
## Status rules
|
||||||
|
|
||||||
|
| Status | Decision |
|
||||||
|
|---|---|
|
||||||
|
| 408 | retry inside deadline and budget |
|
||||||
|
| 425 | at most one retry, first attempt only |
|
||||||
|
| 429 | retry inside `Retry-After`, deadline, and budget |
|
||||||
|
| 401 | one refresh-and-replay, safe replayable operations only |
|
||||||
|
| 500 | denied unless the upstream registered it as transient **and** the operation is safely idempotent |
|
||||||
|
| 502, 503, 504 | retry for safely idempotent operations; ambiguous otherwise |
|
||||||
|
| other 4xx | denied |
|
||||||
|
|
||||||
|
## Ambiguity
|
||||||
|
|
||||||
|
A non-idempotent request that reached `SENT_NO_RESPONSE` raises
|
||||||
|
`HttpAmbiguousExecutionException`. It is a third answer on purpose: retrying may duplicate a side
|
||||||
|
effect, and reporting a plain failure would tell the caller the request did not happen, which may
|
||||||
|
be false. The caller reconciles, usually by querying the upstream or replaying with an idempotency
|
||||||
|
key.
|
||||||
|
|
||||||
|
## Budget and backoff
|
||||||
|
|
||||||
|
Retry tokens come from a per-upstream token bucket sized as a fraction of real traffic, so a failing
|
||||||
|
upstream cannot be flooded by retries from a healthy fleet. Backoff is exponential with full or
|
||||||
|
decorrelated jitter, bounded by `max-backoff`, by `Retry-After`, and by the remaining deadline. No
|
||||||
|
connection and no bulkhead permit is held while a backoff is waiting.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# HTTP Client Platform — Security Guide
|
||||||
|
|
||||||
|
## What the platform owns
|
||||||
|
|
||||||
|
`Authorization`, `Proxy-Authorization`, `Host`, `Content-Length`, `Transfer-Encoding`,
|
||||||
|
`Traceparent`, `Tracestate`, `Baggage`, and (unless a profile opts in) `Cookie` are platform-owned.
|
||||||
|
A caller cannot set them. `Idempotency-Key` is accepted only when the operation declares it. Any
|
||||||
|
header name or value containing CR or LF is rejected before the request is built.
|
||||||
|
|
||||||
|
## Target policy
|
||||||
|
|
||||||
|
A trusted profile accepts only a profile-relative URI template. An absolute URI is rejected rather
|
||||||
|
than sanitised: varying the destination is what H3 is for, and H3 has its own policy, credentials,
|
||||||
|
and address validation. Template variables are encoded per component, so a value containing `/`,
|
||||||
|
`?`, or `#` cannot change the shape of the request.
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
Allowed: TLS 1.2 and 1.3, hostname verification, the JVM trust store, a per-profile custom CA, a
|
||||||
|
per-profile client certificate, mTLS, SNI and ALPN, and certificate rotation through a new runtime
|
||||||
|
generation.
|
||||||
|
|
||||||
|
Forbidden and unrepresentable: a trust-all trust manager, disabled hostname verification, ignoring
|
||||||
|
certificate errors, automatically trusting a production self-signed certificate, falling back to
|
||||||
|
plaintext after an HTTPS failure, and writing key material into configuration or logs.
|
||||||
|
|
||||||
|
Unknown CA, hostname mismatch, expired certificate, revoked certificate, protocol mismatch, and a
|
||||||
|
missing client certificate are permanent. Only a transient handshake timeout may be retried, inside
|
||||||
|
the deadline.
|
||||||
|
|
||||||
|
## Dynamic Target (SSRF)
|
||||||
|
|
||||||
|
Every hop — the first one included — runs the whole flow:
|
||||||
|
|
||||||
|
1. strict URI parse
|
||||||
|
2. scheme allowlist
|
||||||
|
3. reject userinfo and invalid ports
|
||||||
|
4. IDNA-canonicalise the host
|
||||||
|
5. host allowlist or suffix policy
|
||||||
|
6. resolve **every** A and AAAA answer
|
||||||
|
7. normalise each address, including IPv4-mapped IPv6
|
||||||
|
8. reject loopback, link-local, RFC1918, ULA, carrier-grade NAT, unspecified, multicast, cloud
|
||||||
|
metadata, and organisation-defined ranges
|
||||||
|
9. pin the connection to the approved addresses through the same validated resolver
|
||||||
|
10. apply response size and content policy
|
||||||
|
11. repeat for each redirect
|
||||||
|
|
||||||
|
Any forbidden address in the answer set rejects the whole target. Validating only the first answer
|
||||||
|
would let a host that resolves to one public and one private address through.
|
||||||
|
|
||||||
|
Dynamic profiles inherit no API key, OAuth token, Cookie, or default header, and no Cookie jar is
|
||||||
|
created. A specific host may be granted a credential only through an explicitly registered
|
||||||
|
`DynamicCredentialBinding`.
|
||||||
|
|
||||||
|
Application-level validation is not sufficient on its own. A network control — Kubernetes
|
||||||
|
NetworkPolicy, service-mesh egress policy, firewall, or proxy ACL — is an operational completion
|
||||||
|
requirement.
|
||||||
|
|
||||||
|
## Redirects
|
||||||
|
|
||||||
|
Disabled by default. Engine redirect handling is off in every transport so the platform can
|
||||||
|
re-validate each hop. 307 and 308 preserve method and body and are therefore allowed only for a
|
||||||
|
replayable body. Cross-origin hops are refused unless the profile opts in, and when they are
|
||||||
|
allowed `Authorization`, `Proxy-Authorization`, `Cookie`, and API-key headers are stripped.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Allowed tags: `clientName`, `operationName`, `method`, `uriTemplate`, `status`, `outcome`,
|
||||||
|
`transport`, `protocol`, `timeoutType`, `retryReason`, `evidence`, `circuitState`.
|
||||||
|
|
||||||
|
Rejected outright: full URL, query parameters, path variable values, user ID, raw tenant ID,
|
||||||
|
resolved IP, API key, token, Cookie, idempotency key, request or response body, exception message.
|
||||||
|
|
||||||
|
Failures are logged once, structured, at the end of a logical call. Retry attempts are DEBUG or span
|
||||||
|
events. URLs appear only as templates.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Streaming and Large Bodies
|
||||||
|
|
||||||
|
## Response lifecycle
|
||||||
|
|
||||||
|
A blocking streaming download returns `BlockingStreamingResponse`, never a bare `InputStream`.
|
||||||
|
Closing is idempotent and always releases the connection — after a full read, a partial read, a
|
||||||
|
decode failure, or a size rejection. The status is validated before any body byte is delivered, so a
|
||||||
|
failed download never becomes a half-consumed stream the caller has to reason about.
|
||||||
|
|
||||||
|
A reactive download emits bounded `DataBuffer` values. Buffers are released on completion, error,
|
||||||
|
and cancellation; a dropped buffer is direct memory nobody returns.
|
||||||
|
|
||||||
|
Wire bytes and decoded bytes are bounded independently, because a compressed payload passes a wire
|
||||||
|
check and then expands. Limits are enforced while reading, not after buffering.
|
||||||
|
|
||||||
|
## The first-byte boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
response headers received
|
||||||
|
→ nothing delivered yet
|
||||||
|
→ a read-only operation may still be retried
|
||||||
|
→ first InputStream read or first Flux onNext
|
||||||
|
→ transparent retry is permanently disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
`FirstByteDeliveryGuard` latches once and never resets. Retrying after delivery would replay a
|
||||||
|
stream the caller has already partly consumed, producing duplicated or reordered data that no
|
||||||
|
downstream code can detect.
|
||||||
|
|
||||||
|
## Request bodies
|
||||||
|
|
||||||
|
A reopenable body is opened once per attempt, which is what makes it replayable; reusing the
|
||||||
|
previous stream would silently send an empty body on the retry. A one-shot stream or publisher
|
||||||
|
instance is never retried. `ReactiveBodySource` takes a publisher *factory* rather than a publisher
|
||||||
|
so a reactive body can honestly declare itself replayable.
|
||||||
|
|
||||||
|
A multipart body is exactly as replayable as its weakest part.
|
||||||
|
|
||||||
|
## Server-sent events
|
||||||
|
|
||||||
|
Three budgets stay separate:
|
||||||
|
|
||||||
|
- `setupDeadline` — establishing the stream
|
||||||
|
- `streamingIdleTimeout` — silence once it is open
|
||||||
|
- `maxStreamDuration` — optional total lifetime
|
||||||
|
|
||||||
|
Applying the request-shaped `total-call` timeout to an SSE subscription would terminate a perfectly
|
||||||
|
healthy stream on schedule, so it is not applied.
|
||||||
|
|
||||||
|
`Last-Event-ID` is opt-in. Replaying from an id is only correct when the producer guarantees it;
|
||||||
|
sending it blindly can skip or duplicate events. Reconnects consume the retry budget like any other
|
||||||
|
physical attempt, and cancelling the subscription stops both the stream and any pending reconnect.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# HTTP Client Platform — Support Matrix
|
||||||
|
|
||||||
|
Grades follow design §6 and §29. A row is **Stable** only when the cross-transport contract suite
|
||||||
|
proves it; anything the suite cannot prove is **Experimental** and says so.
|
||||||
|
|
||||||
|
## Spring API
|
||||||
|
|
||||||
|
| API | Grade | Role | Constraint |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `RestClient` | Stable | Blocking execution | Bounded concurrency and an effective deadline are mandatory |
|
||||||
|
| `WebClient` | Stable | Reactive, streaming, SSE | No blocking work on the event loop |
|
||||||
|
| HTTP Service Client (`@HttpExchange`) | Default | Declarative typed client | Operation metadata is mandatory |
|
||||||
|
| `RestTemplate` | Migration only | Moving existing calls | No new profile or feature |
|
||||||
|
| Generic Exchange (H2) | Restricted | Dynamic method, path, body | Base URL and policy are immutable |
|
||||||
|
| Dynamic Target (H3) | Restricted | User-supplied URL | Separate SSRF policy; inherits no credential |
|
||||||
|
| Native engine | Internal | Engine-specific configuration | Never an application-facing API |
|
||||||
|
|
||||||
|
## Transports
|
||||||
|
|
||||||
|
| Transport | Blocking | Reactive | HTTP/1.1 | HTTP/2 | HTTP/3 | Grade | Verified by |
|
||||||
|
|---|---:|---:|---:|---:|---:|---|---|
|
||||||
|
| Apache HttpClient 5 (classic) | yes | no | yes | **no** | no | Stable (blocking default) | `httpClientStableContractTest`, `NegotiatedProtocolContractTest` |
|
||||||
|
| JDK HttpClient | yes | `sendAsync` | yes | yes (TLS/ALPN) | no | Stable (lightweight, blocking HTTP/2) | `NegotiatedProtocolContractTest` |
|
||||||
|
| Reactor Netty | limited | yes | yes | yes | experimental | Stable (reactive default) | `NegotiatedProtocolContractTest` |
|
||||||
|
| Jetty | facade | yes | yes | yes | yes | **Experimental** | `Http3OptInTest` only |
|
||||||
|
| Simple request factory | yes | no | limited | no | no | Local test only | rejected in production by `ClientProfileValidator` |
|
||||||
|
|
||||||
|
### Apache is HTTP/1.1 here, and why
|
||||||
|
|
||||||
|
Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable, and the library is — in its **async**
|
||||||
|
client. Spring's `HttpComponentsClientHttpRequestFactory` drives the **classic** client, which
|
||||||
|
speaks HTTP/1.1 only. `NegotiatedProtocolContractTest` measures this rather than assuming it: the
|
||||||
|
classic client fails outright against a prior-knowledge h2c server.
|
||||||
|
|
||||||
|
So `ApacheBlockingTransportProvider.capabilities()` declares HTTP/1.1, and a profile that pairs
|
||||||
|
Apache with `HTTP_2` is rejected at startup instead of quietly running HTTP/1.1 while this table
|
||||||
|
claims otherwise. **Blocking HTTP/2 is served by the JDK transport**; reactive HTTP/2 by Reactor
|
||||||
|
Netty. Both are measured from the client after a real TLS handshake, not read from configuration.
|
||||||
|
|
||||||
|
The JDK transport declares `routeScopedPool=false`, `boundedPendingAcquireQueue=false`, and
|
||||||
|
`dynamicTargetStable=false`. A profile that needs any of those is rejected at startup rather than
|
||||||
|
served with weaker guarantees. Choosing between Apache and JDK is therefore a real trade: Apache
|
||||||
|
gives route-scoped pooling and Dynamic Target pinning, JDK gives HTTP/2.
|
||||||
|
|
||||||
|
## Capability gates
|
||||||
|
|
||||||
|
| Capability | Gate |
|
||||||
|
|---|---|
|
||||||
|
| Dynamic Target (H3) | Apache and Reactor Netty only; JDK and Jetty are rejected |
|
||||||
|
| HTTP/3 | `experimentalAcknowledgement` must equal `I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS` |
|
||||||
|
| Cross-origin redirect | opt-in per profile; credentials are stripped on the hop |
|
||||||
|
| Retry | evidence-based; never enabled by HTTP method alone |
|
||||||
|
|
||||||
|
## CI matrix
|
||||||
|
|
||||||
|
| Profile | Frequency | Release gate | Task |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Spring Framework 7.0 (repository baseline) | every PR | required | `spring70CompatibilityTest` |
|
||||||
|
| Spring Framework 6.2 API surface | every PR | required | `spring62CompatibilityTest` |
|
||||||
|
| Apache HC5 + RestClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=apache` |
|
||||||
|
| JDK HttpClient + RestClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=jdk` |
|
||||||
|
| Reactor Netty + WebClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=reactor` |
|
||||||
|
| SSRF / cardinality suite | every PR | required | `httpClientSecurityTest` |
|
||||||
|
| Toxiproxy fault suite | nightly, release | required | `httpClientFailureInjectionTest` |
|
||||||
|
| Event-loop blocking (BlockHound) | every PR | required | `httpClientBlockHoundTest` |
|
||||||
|
| Performance certification | nightly, release | required | `httpClientPerformanceTest -Pperformance.assertions.enabled=true` |
|
||||||
|
| Jetty HTTP/3 | nightly | Experimental, non-blocking | `test -Phttp3.tests.enabled=true` |
|
||||||
|
|
||||||
|
### Known limitation of the Spring 6.2 lane
|
||||||
|
|
||||||
|
This repository's Spring Boot 4.0 baseline pins Spring Framework 7, so a real 6.2 runtime cannot be
|
||||||
|
resolved here. `spring62CompatibilityTest` therefore verifies the **API surface**: the common
|
||||||
|
packages must not reference any Spring 7-only type, and `org.springframework.web.service.registry`
|
||||||
|
is confined to `…httpclient.spring7`. Executing the suite against an actual 6.2 distribution
|
||||||
|
requires a host project on that line. This limitation is stated rather than hidden behind a passing
|
||||||
|
check.
|
||||||
|
|
||||||
|
|
||||||
|
## What the suites do not prove
|
||||||
|
|
||||||
|
Stated so the matrix is read as a measurement rather than an aspiration.
|
||||||
|
|
||||||
|
| Gap | Why | What is proven instead |
|
||||||
|
|---|---|---|
|
||||||
|
| HTTP/2 frame injection (`REFUSED_STREAM`, arbitrary `GOAWAY`) | The fixture server exposes no frame-level control, and a purpose-built h2 server is a larger dependency than the guarantee is worth here | `Http2EvidenceMapperTest` proves the frame → evidence mapping, and `NegotiatedProtocolContractTest` proves h2 is really negotiated |
|
||||||
|
| Netty buffer-leak detection | Netty reports a leak when an unreferenced buffer is collected, which the suite does not force | `NettyLeakDetectionExtension` asserts the PARANOID detector is live and reports nothing; explicit release assertions in the streaming suites are the primary guarantee |
|
||||||
|
| Spring 6.2 runtime | This repository's Boot 4.0 baseline pins Spring 7 | `spring62CompatibilityTest` confines the common packages to the 6.2 API surface |
|
||||||
|
| Performance latency baseline | Numbers measured on a build agent are not a certification | `httpClientPerformanceTest` asserts structural bounds unconditionally; latency and heap bounds run under `-Pperformance.assertions.enabled=true` |
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Command policy
|
||||||
|
|
||||||
|
`src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml` is the
|
||||||
|
single source of truth for what this SDK is willing to do with each Redis command. Official server
|
||||||
|
metadata decides what a command *is*; this file decides what we allow.
|
||||||
|
|
||||||
|
A command that is not classified there is refused. Adding a command therefore means editing that
|
||||||
|
file, not writing code — and the edit is where the risk decision is made and reviewed.
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
| Field | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `risk` | required | `R1` routine, `R2` needs an explicit permit, `R3` administrative, `R4` never allowed |
|
||||||
|
| `support` | required | `TYPED`, `ADVANCED_TYPED`, `RAW_ONLY`, `ADMIN_ONLY`, `VERSION_GATED`, `BLOCKED` |
|
||||||
|
| `minimum-version` | `7.2` | lowest server version that carries the command |
|
||||||
|
| `access` | derived from `support` | which ACL account may issue it |
|
||||||
|
| `blocking` | `false` | occupies its connection until the server replies |
|
||||||
|
| `optional-block` | `false` | the command also has a non-blocking form; only `XREAD` and `XREADGROUP` carry it |
|
||||||
|
| `read-only` | `false` | never mutates the dataset |
|
||||||
|
| `retry-safe` | `read-only` | may be retried after a failure that could have reached the server |
|
||||||
|
| `may-be-ambiguous` | `!read-only` | a failure may leave the outcome unknown |
|
||||||
|
| `timeout-profile` | derived | `FAST`, `COLLECTION`, `ADMIN`, `BLOCKING` |
|
||||||
|
| `key-spec` | `1 1 1` | where the keys are, or `none`, or `movable` |
|
||||||
|
| `required-policy` | – | the permit policy an R2 command demands |
|
||||||
|
|
||||||
|
## Rules the catalog enforces
|
||||||
|
|
||||||
|
- An R2 `ADVANCED_TYPED` command must name the permit policy it requires. There is no R2 command
|
||||||
|
that anyone may issue without an issued permit.
|
||||||
|
- An R4 command must be `BLOCKED`, and an R3 command must be `ADMIN_ONLY`. The type system refuses
|
||||||
|
the other combinations at load time.
|
||||||
|
- A `BLOCKED` command carries no ACL account, so no path in the SDK can reach it.
|
||||||
|
- A blocking command must use the `BLOCKING` timeout profile, and its request must declare a bounded
|
||||||
|
server block — unless it also declares `optional-block`, which only the two stream reads do.
|
||||||
|
- Deprecated command names stay `BLOCKED` even when the SDK offers their behaviour. The typed
|
||||||
|
sorted-set ranges issue `ZRANGE ... BYSCORE|BYLEX|REV`, not `ZRANGEBYSCORE`, so what the guard was
|
||||||
|
told and what reaches the wire are the same command.
|
||||||
|
|
||||||
|
## Where each support level is reachable from
|
||||||
|
|
||||||
|
| Support | Reachable from |
|
||||||
|
| --- | --- |
|
||||||
|
| `TYPED` | the typed operations, no permit |
|
||||||
|
| `ADVANCED_TYPED` | the typed operations, with the named permit |
|
||||||
|
| `VERSION_GATED` | a capability bean that exists only when the probe found the feature |
|
||||||
|
| `RAW_ONLY` | `sdk.raw`, and only with a deployment-registered approval |
|
||||||
|
| `ADMIN_ONLY` | `sdk.admin`, read-only diagnostics only |
|
||||||
|
| `BLOCKED` | nowhere |
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Operating the Redis SDK
|
||||||
|
|
||||||
|
## What the metrics can and cannot tell you
|
||||||
|
|
||||||
|
Every observation carries the command family, the deployment mode, and latency. None carries a key,
|
||||||
|
a field, a member, or a value — not because they would be large, but because a metric dimension
|
||||||
|
built from caller data is unbounded cardinality and, for most deployments, tenant identity in a
|
||||||
|
dashboard.
|
||||||
|
|
||||||
|
That means you can answer "which command family is slow" and "which one is failing", and you cannot
|
||||||
|
answer "which key is hot" from metrics. Use the admin plane's `SLOWLOG` projection for the first
|
||||||
|
question and `MEMORY USAGE` on a specific key for the second.
|
||||||
|
|
||||||
|
## The failures worth alerting on
|
||||||
|
|
||||||
|
| Signal | What it means | What to do |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `RedisCommandRejectedException` | the SDK refused before sending | a caller exceeded a declared bound; the reason names which one |
|
||||||
|
| `RedisCrossSlotException` | a multi-key command spans slots | the keys need a shared hash tag |
|
||||||
|
| `RedisAmbiguousExecutionException` | a write may or may not have applied | reconcile; the SDK will not retry it |
|
||||||
|
| `RedisCapabilityUnavailableException` | the server lacks the feature | a capability bean was constructed by hand, or the probe result changed |
|
||||||
|
| `SentinelFailoverObserver.ambiguousWriteCount` | non-idempotent writes lost to a promotion | each one needs reconciling; the count is the workload |
|
||||||
|
| `ClusterTopologyObserver.reshardingObserved` | `ASK`/`TRYAGAIN` seen | a slot migration is in progress; latency will be uneven until it ends |
|
||||||
|
|
||||||
|
## Things the SDK will never do for you
|
||||||
|
|
||||||
|
- Retry a non-idempotent write after a timeout. `ExecutionCertainty.AMBIGUOUS_FAILURE` is reported,
|
||||||
|
not resolved.
|
||||||
|
- Follow a cross-slot multi-key command by splitting it. It is refused instead.
|
||||||
|
- Read a whole collection, stream, or index. Every read declares a bound.
|
||||||
|
- Load a Lua script or a function library at request time. Both are deployment actions.
|
||||||
|
- Send a command it cannot classify.
|
||||||
|
- Tell you that an acknowledged write was lost. See below — this one is not a limitation you can
|
||||||
|
work around in application code.
|
||||||
|
|
||||||
|
## The write loss the client cannot see
|
||||||
|
|
||||||
|
Set these on every Redis node that can ever be a primary:
|
||||||
|
|
||||||
|
```
|
||||||
|
min-replicas-to-write 1
|
||||||
|
min-replicas-max-lag 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Without them a Sentinel promotion silently destroys acknowledged writes, and this is measured, not
|
||||||
|
theoretical. In `LiveRedisSentinelPromotionTest` on the 7.4 lane, Sentinel promoted the replica and
|
||||||
|
did not demote the old primary for **eleven seconds**. The client stayed connected to a primary that
|
||||||
|
had already been replaced, wrote, and was told `+OK` **2,086 times**. Every one of those writes was
|
||||||
|
discarded when the old primary resynced. Exactly one command failed.
|
||||||
|
|
||||||
|
Nothing on the client can detect this. The server answered, so the driver recorded a success, the
|
||||||
|
SDK recorded `CONFIRMED_SUCCESS`, and the caller was told the write landed. No metric here counts
|
||||||
|
it, `SentinelFailoverObserver` cannot count it, and no retry policy helps — there was no failure to
|
||||||
|
react to. A second run of the same promotion produced sixteen thousand writes, **zero** exceptions,
|
||||||
|
and the same silent loss.
|
||||||
|
|
||||||
|
With the two settings, the identical promotion lost **one** write and refused 2,020 with
|
||||||
|
`NOREPLICAS`, which the SDK reports as a definite, non-ambiguous failure the caller can act on. That
|
||||||
|
is the whole difference: an outage you can see instead of data you cannot.
|
||||||
|
|
||||||
|
The residual window is `min-replicas-max-lag` wide and cannot be closed by configuration alone. A
|
||||||
|
write that must survive a promotion under any circumstances needs `WAIT` after it, at the cost of a
|
||||||
|
round trip to the replica — decide that per write, not globally.
|
||||||
|
|
||||||
|
## Blocking work
|
||||||
|
|
||||||
|
Blocking pops and blocking stream reads run on a dedicated connection lane. If those saturate, the
|
||||||
|
symptom is blocking calls timing out while ordinary traffic is healthy — that is the lane doing its
|
||||||
|
job, not a fault. Size the blocking pool to the number of concurrent consumers, not to request rate.
|
||||||
|
|
||||||
|
## Pub/Sub
|
||||||
|
|
||||||
|
At-most-once. A subscriber that reconnects misses whatever arrived while it was gone, and there is
|
||||||
|
no replay. Durable business events belong in a stream with a consumer group, which is at-least-once
|
||||||
|
and therefore requires idempotent consumers.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Redis SDK support matrix
|
||||||
|
|
||||||
|
This file is a gate, not a summary. `RedisSupportMatrixTest` parses the tables below and fails when
|
||||||
|
the SDK grows a package or a capability that is not listed, so a module cannot ship without someone
|
||||||
|
stating its minimum version, its topology support, and what it does not do.
|
||||||
|
|
||||||
|
Design: `docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md`.
|
||||||
|
Delivery status and the decisions behind each module: `docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-status.md`.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Minimum Redis | Topology | Risk exposure | Sync | Reactive | Known limitations |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `api` | 7.2 | all | none | n/a | n/a | contract only; no driver types |
|
||||||
|
| `api/key` | 7.2 | all | none | n/a | n/a | slot tags must be low-cardinality |
|
||||||
|
| `api/codec` | 7.2 | all | none | n/a | n/a | no Java native serialization |
|
||||||
|
| `api/command` | 7.2 | all | none | n/a | n/a | permits never widen the ACL account |
|
||||||
|
| `api/error` | 7.2 | all | none | n/a | n/a | failure metadata carries no key or value |
|
||||||
|
| `api/operations` | 7.2 | all | none | n/a | n/a | contract only |
|
||||||
|
| `api/reactive` | 7.2 | all | none | n/a | n/a | Reactor confined to this package |
|
||||||
|
| `lettuce` | 7.2 | all | R1–R2 | yes | yes | pinned to Lettuce 6.8.2 |
|
||||||
|
| `lettuce/codec` | 7.2 | all | none | yes | yes | UTF-8 and byte array codecs only |
|
||||||
|
| `lettuce/command` | 7.2 | all | R1–R2 | yes | yes | policy catalog is the only command authority |
|
||||||
|
| `lettuce/connection` | 7.2 | all | none | yes | yes | five lanes; blocking work never shares the regular lane |
|
||||||
|
| `lettuce/observability` | 7.2 | all | none | yes | yes | command family only, never a key |
|
||||||
|
| `lettuce/operations` | 7.2 | all | R1–R2 | yes | yes | hash field TTL needs 7.4; sharded pub/sub needs 7.0; stream deletion needs 8.2 |
|
||||||
|
| `config` | 7.2 | all | none | n/a | n/a | permit provenance is HMAC-signed per process |
|
||||||
|
| `cluster` | 7.2 | cluster | none | n/a | n/a | slot arithmetic only; no redirect following |
|
||||||
|
| `programmability` | 7.2 | all | R2 | yes | no | transactions never roll back; scripts return one bulk reply; `FUNCTION LOAD` is admin-plane |
|
||||||
|
| `raw` | 7.2 | all | R2 | yes | no | `RAW_ONLY` commands only; movable key specs unapprovable |
|
||||||
|
| `admin` | 7.2 | all | R3 read-only | yes | no | replies are projected; no destructive command exists |
|
||||||
|
| `extensions` | 8.0 | all | none | yes | no | shared command runner; every extension declares its key |
|
||||||
|
| `extensions/json` | 8.0 | all | R1–R2 | yes | no | narrow JSONPath grammar; documents exchanged as text |
|
||||||
|
| `extensions/search` | 8.0 | all | R2 | yes | no | index names namespaced by the SDK; no drop index |
|
||||||
|
| `extensions/timeseries` | 8.0 | all | R1–R2 | yes | no | retention mandatory at creation |
|
||||||
|
| `extensions/probabilistic` | 8.0 | all | R1–R2 | yes | no | every answer is approximate by construction |
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
| Capability | Minimum Redis | Gate | Bean when absent |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `SHARDED_PUBSUB` | 7.0 | probe and catalog minimum | none |
|
||||||
|
| `FUNCTIONS` | 7.0 | probe and catalog minimum | none |
|
||||||
|
| `HASH_FIELD_EXPIRATION` | 7.4 | probe and catalog minimum | none |
|
||||||
|
| `HASH_FIELD_EXPIRATION_COMBINED` | 8.0 | probe and catalog minimum | none |
|
||||||
|
| `STREAM_ACKNOWLEDGE_DELETE` | 8.2 | probe and catalog minimum | none |
|
||||||
|
| `STREAM_NEGATIVE_ACKNOWLEDGE` | 8.8 | probe and catalog minimum | none, and no bean exists yet |
|
||||||
|
| `JSON` | 8.0 | probe is authoritative | none |
|
||||||
|
| `SEARCH` | 8.0 | probe is authoritative | none |
|
||||||
|
| `TIME_SERIES` | 8.0 | probe is authoritative | none |
|
||||||
|
| `PROBABILISTIC` | 8.0 | probe is authoritative | none |
|
||||||
|
|
||||||
|
## Certified versions
|
||||||
|
|
||||||
|
A version is certified by its lane producing evidence, not by the version number being newer. An
|
||||||
|
evidence claim here must name the test class that produced it; `RedisSupportMatrixTest` fails the
|
||||||
|
build on a row that claims anything else, so "verified" cannot be written into this table without a
|
||||||
|
test behind it.
|
||||||
|
|
||||||
|
All three lanes have now run on 7.4. The other declared versions are declared, not certified:
|
||||||
|
nothing in this repository has executed against 7.2 or 8.2.
|
||||||
|
|
||||||
|
| Topology | Versions declared | Evidence status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Standalone | 7.2, 7.4, 8.2 | `RedisTopologyContractTest`, `LiveRedisGuardrailTest` on 7.4 |
|
||||||
|
| Sentinel | 7.4, 8.2 | `RedisTopologyContractTest`, `LiveRedisSentinelPromotionTest` on 7.4 |
|
||||||
|
| Cluster | 7.4, 8.2 | `RedisTopologyContractTest`, `LiveRedisClusterTest` on 7.4 |
|
||||||
|
|
||||||
|
### What the standalone ACL run established
|
||||||
|
|
||||||
|
`RedisTopologyContractTest` runs the four accounts in `infra/redis-sdk/acl` against a live server and
|
||||||
|
asserts that each `CommandAccess` level grants exactly what the command policy catalog says it may
|
||||||
|
issue. Writing it found five defects that no amount of reading the files would have surfaced:
|
||||||
|
|
||||||
|
1. A Redis ACL file accepts neither comments nor line continuations — the original files did not load
|
||||||
|
at all, and the server refused to start.
|
||||||
|
2. The advanced account granted `SMEMBERS` and `SORT`, both `RAW_ONLY` and therefore the raw gateway
|
||||||
|
account's alone.
|
||||||
|
3. The ordinary account granted `SORT_RO` for the same reason.
|
||||||
|
4. The ordinary account could not run `PUBLISH`, `SUBSCRIBE`, or `PING`, all classified `TYPED`.
|
||||||
|
5. The ordinary account could not run `MULTI`, `EXEC`, `UNWATCH`, or `DISCARD`, also `TYPED`.
|
||||||
|
|
||||||
|
6. The admin account was missing twelve read-only diagnostics the catalog exposes — the `OBJECT`,
|
||||||
|
`PUBSUB`, `XINFO`, `FUNCTION LIST`/`STATS`, and `CLUSTER KEYSLOT` subcommands.
|
||||||
|
|
||||||
|
7. The cursor-scan reply budget was sized to the requested `COUNT`, which Redis treats as a hint —
|
||||||
|
a real `HSCAN COUNT 500` came back with 501 entries and the SDK refused a correct reply.
|
||||||
|
|
||||||
|
Points 2 and 3 are the ones that matter: the account is the last enforcement boundary, so an account
|
||||||
|
wider than the catalog silently removes the second control the design relies on.
|
||||||
|
|
||||||
|
### What the standalone guardrail run established
|
||||||
|
|
||||||
|
`LiveRedisGuardrailTest` wires the real guard, catalog, and typed operations to a live server —
|
||||||
|
the first time `LettuceRedisCommandGateway`, the one class that encodes commands, runs under the
|
||||||
|
SDK's own contracts rather than against the in-memory stand-in. It carries the plan's datasets: a
|
||||||
|
value at the 1 MiB ceiling, a hundred-thousand-field hash, hundred-thousand-member set and sorted
|
||||||
|
set, a twenty-thousand-element list, a stream trimmed to 1,000 while twenty thousand entries are
|
||||||
|
appended, and a five-hundred-command batch.
|
||||||
|
|
||||||
|
The assertions are about limits holding, not throughput. A guardrail test that measured absolute
|
||||||
|
speed would fail on a loaded laptop and teach nobody anything.
|
||||||
|
|
||||||
|
### What the Sentinel promotion run established
|
||||||
|
|
||||||
|
`LiveRedisSentinelPromotionTest` forces one real promotion and asserts several independent claims
|
||||||
|
about it. Every write carries a token unique to the run, so the list on the promoted primary is a
|
||||||
|
verbatim record of what happened and each per-call verdict can be checked against it.
|
||||||
|
|
||||||
|
It found the most serious defect in this delivery, and it is not in the SDK's code:
|
||||||
|
|
||||||
|
> **A superseded primary keeps acknowledging writes.** Sentinel promoted the replica at
|
||||||
|
> `05:56:12.503` and did not demote the old primary until `05:56:23.529` — eleven seconds in which
|
||||||
|
> the client, still connected, wrote and was told `+OK` **2,086 times**. Every one of those writes
|
||||||
|
> was discarded when the old primary resynced from the new one. Exactly **one** command failed. No
|
||||||
|
> client-side signal exists for this: the server answered, so the driver, the SDK, and the caller
|
||||||
|
> all correctly recorded a success.
|
||||||
|
|
||||||
|
`SentinelFailoverObserver` counts *ambiguous* writes, and its documentation used to call those "the
|
||||||
|
ones an operator has to reconcile". That was wrong by three orders of magnitude, and the class now
|
||||||
|
says so.
|
||||||
|
|
||||||
|
What closes the window is on the server, not the client. Re-running the identical promotion with
|
||||||
|
`min-replicas-to-write 1` and `min-replicas-max-lag 1` configured cut acknowledged-and-discarded
|
||||||
|
writes from **2,086 to 1**: the orphaned primary refused 2,020 writes with `NOREPLICAS`, which the
|
||||||
|
SDK translates to a definite, non-ambiguous failure the caller can act on. Both settings are now in
|
||||||
|
the lane, and `acknowledgedWriteLossIsBounded` ties the tolerated loss to the configured lag window,
|
||||||
|
so removing them makes the count jump by an order of magnitude and fails the test.
|
||||||
|
|
||||||
|
That assertion then caught a second version of the same mistake within a day of being written. The
|
||||||
|
first guarded run passed; the second failed with 2,099 lost writes, because the setting had been
|
||||||
|
written into the lane's `primary` service only. The two data nodes swap roles on every failover, so
|
||||||
|
a guardrail applied to whichever one happens to start as primary stops applying the moment the lane
|
||||||
|
does the thing it exists to do. Both nodes now take their whole configuration from one definition.
|
||||||
|
Three consecutive promotions in both directions since: 0, 0, and 1 acknowledged write lost.
|
||||||
|
|
||||||
|
The run also found a translator defect. A promotion closed the channel under an in-flight `RPUSH`
|
||||||
|
and the driver raised a bare `RedisException`, which matched no branch and fell through to a generic
|
||||||
|
failure reported as *definitely did not run*. Nothing about an unrecognised failure supports that
|
||||||
|
claim, and a caller who believes it retries a non-idempotent write. The fallback now treats an
|
||||||
|
unclassified write failure as ambiguous.
|
||||||
|
|
||||||
|
### What the Cluster run established
|
||||||
|
|
||||||
|
`LiveRedisClusterTest` checks the part of `sdk.cluster` that is pure client-side arithmetic against
|
||||||
|
the server that has the last word. The calculator agreed with `CLUSTER KEYSLOT` on every entry of a
|
||||||
|
corpus built from the brace rules a hand-written implementation gets wrong — an empty tag `{}`,
|
||||||
|
`foo{}{bar}`, `foo{{bar}}zap`, an unclosed brace, `}{`, the empty key, and non-ASCII keys — and the
|
||||||
|
rendered-key invariant holds: the slot the SDK computes from a tag alone equals the slot the server
|
||||||
|
computes from the whole rendered key.
|
||||||
|
|
||||||
|
Cross-slot refusal was checked in both directions, because a guard stricter than the cluster costs
|
||||||
|
availability for no reason and a looser one sends requests that cannot succeed. The same key pair
|
||||||
|
the guard refuses is the pair the server answers `CROSSSLOT` for.
|
||||||
|
|
||||||
|
Redirects were observed rather than assumed: a `MOVED` names the slot the client computed, and a
|
||||||
|
slot put into a real `MIGRATING`/`IMPORTING` state answers `ASK` for an absent key and `TRYAGAIN`
|
||||||
|
for a multi-key request that straddles the migration. The lane restores the slot to `STABLE`
|
||||||
|
afterwards, so a run leaves the cluster as it found it.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Redis and client upgrade gate
|
||||||
|
|
||||||
|
Changing the Redis server version or the Lettuce version is not a dependency bump. Both change what
|
||||||
|
commands exist, what they reply, and what an ACL account is allowed to do — all three are things this
|
||||||
|
SDK encodes as fixed decisions. The checks below must pass before either version moves, and each one
|
||||||
|
exists because skipping it produces a specific failure that only shows up in production.
|
||||||
|
|
||||||
|
## 1. Command metadata diff
|
||||||
|
|
||||||
|
Run the catalog drift check against the new server. Every command the server reports must be
|
||||||
|
classified in `src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml`.
|
||||||
|
|
||||||
|
*Why:* an unclassified command is refused by `CommandPolicyGuard`, so a server that grew a command
|
||||||
|
does not create a hole — but a command whose **risk changed upstream** and is still classified R1
|
||||||
|
here does. The diff is what surfaces that.
|
||||||
|
|
||||||
|
## 2. ACL regression
|
||||||
|
|
||||||
|
Re-run `ACL DRYRUN` for every account against every command the SDK can issue, using
|
||||||
|
`RedisAdminOperations.aclDryRun`.
|
||||||
|
|
||||||
|
*Why:* a permit never widens an ACL account, so the account is the last boundary. A new server
|
||||||
|
version that moved a command into a different ACL category silently turns a working call into a
|
||||||
|
runtime refusal on the first request that needs it.
|
||||||
|
|
||||||
|
## 3. Serializer golden bytes
|
||||||
|
|
||||||
|
Compare the encoded form of every registered codec against the stored golden bytes.
|
||||||
|
|
||||||
|
*Why:* a value written by the old version must still decode after the upgrade. A codec change that
|
||||||
|
looks harmless in a round-trip test is not harmless against data already in the instance.
|
||||||
|
|
||||||
|
## 4. Support matrix
|
||||||
|
|
||||||
|
Update `docs/redis/support-matrix.md`. `RedisSupportMatrixTest` fails when a module or capability is
|
||||||
|
missing, and the certified-version table must not claim a version until its topology lane has
|
||||||
|
actually run.
|
||||||
|
|
||||||
|
## 5. Topology suite
|
||||||
|
|
||||||
|
Run the standalone, Sentinel, and Cluster lanes declared in `infra/redis-sdk/`. A version is
|
||||||
|
certified by the lane passing, not by the version number being newer.
|
||||||
|
|
||||||
|
*Why:* failover certainty and cross-slot behaviour are the two things the in-memory fixture cannot
|
||||||
|
prove. `ExecutionCertainty` and `RedisSlotCalculator` are classification and arithmetic; whether the
|
||||||
|
driver actually behaves that way during a promotion or a resharding is only observable on a real
|
||||||
|
topology.
|
||||||
|
|
||||||
|
## 6. Rollback
|
||||||
|
|
||||||
|
Before the upgrade, record the previous server version, the previous Lettuce version, and the
|
||||||
|
`SCRIPT LOAD` digests of every registered script. A rollback is not complete until the digests
|
||||||
|
resolve again on the restored version.
|
||||||
|
|
||||||
|
*Why:* digests are cached per process and invalidated by `SCRIPT FLUSH` and by restarts. A rollback
|
||||||
|
that leaves a process holding digests the restored server does not know produces `NOSCRIPT` on
|
||||||
|
every scripted call until the cache is dropped.
|
||||||
|
|
||||||
|
## What this gate does not cover
|
||||||
|
|
||||||
|
Data migration. Nothing here moves or reshapes stored values; a change that alters what is stored,
|
||||||
|
rather than how it is addressed, needs its own plan.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Registry: Repository Access Capabilities
|
||||||
|
# SSOT: wiki/projects/ca-tmpl/registries/capabilities.yaml
|
||||||
|
# Schema owner: feature-contract-registry-governance
|
||||||
|
# Owner branch: feature-repository-access-permission-contract
|
||||||
|
# Last updated: 2026-06-05
|
||||||
|
#
|
||||||
|
# Notes
|
||||||
|
# - capability는 사용자 권한이 아니라 application use case가 infrastructure capability를
|
||||||
|
# 사용할 수 있는지에 대한 계약 (feature-repository-access-permission-contract).
|
||||||
|
# - enforcement default = ArchUnit annotation-based rule. compile-time annotation processor는
|
||||||
|
# alternative. runtime AOP는 forbidden.
|
||||||
|
# - capability 제거는 항상 breaking change. 추가는 additive (registry row 동반 시).
|
||||||
|
# - annotation 표기 (as-built, F1/F2 reconciled 2026-06-05): 코드 SSOT는 단일
|
||||||
|
# `@UseCaseCapability` (TYPE target, typed attribute). 노트 D2/D11의 flat
|
||||||
|
# `@UseCaseRepositoryAccess(Capability[])` 모델은 superseded. 7 capability ↔ as-built 매핑:
|
||||||
|
# READ_REPOSITORY/WRITE_REPOSITORY → repositoryAccess, TRANSACTION_REQUIRED → transactionMode,
|
||||||
|
# EXTERNAL_OUTBOUND_ALLOWED → externalOutboundAllowed, SENSITIVE_READ → sensitiveRead,
|
||||||
|
# BULK_WRITE → bulkWrite, CROSS_TENANT_ADMIN → crossTenantAdmin.
|
||||||
|
# 각 row의 annotation: 필드는 아래에서 as-built 표기로 정합됨.
|
||||||
|
|
||||||
|
capabilities:
|
||||||
|
|
||||||
|
# source: feature-repository-access-permission-contract — 판정 기준 "Required capability: READ_REPOSITORY"
|
||||||
|
# source: feature-application-port-usecase-contract — "read-only query use case는 readOnly 와 READ_REPOSITORY capability만 선언 가능"
|
||||||
|
- name: READ_REPOSITORY
|
||||||
|
scope: use_case_method
|
||||||
|
enforcement: archunit
|
||||||
|
annotation: "@UseCaseCapability(repositoryAccess = READ_REPOSITORY)"
|
||||||
|
semantics: "use case가 read-only repository operation을 호출하는 것을 허용. query use case의 기본 capability. write/sensitive/bulk 작업은 별도 capability 선언이 없으면 forbidden."
|
||||||
|
owner_branch: feature-repository-access-permission-contract
|
||||||
|
bound_to_capability: null
|
||||||
|
threshold: null
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: architecture-enforcement:read-repository-capability
|
||||||
|
|
||||||
|
# source: feature-repository-access-permission-contract — 판정 기준 "Required capability: WRITE_REPOSITORY"
|
||||||
|
# source: feature-application-port-usecase-contract — "write use case는 transactionMode, idempotency, repositoryAccess를 명시해야 함"
|
||||||
|
- name: WRITE_REPOSITORY
|
||||||
|
scope: use_case_method
|
||||||
|
enforcement: archunit
|
||||||
|
annotation: "@UseCaseCapability(repositoryAccess = WRITE_REPOSITORY)"
|
||||||
|
semantics: "use case가 mutating repository operation(insert/update/delete)을 호출하는 것을 허용. 단일/소량 write 기준이며 batch size > 100은 BULK_WRITE 별도 선언 필요. read-only use case에서 이 capability 없이 write repository 접근하면 fail."
|
||||||
|
owner_branch: feature-repository-access-permission-contract
|
||||||
|
bound_to_capability: null
|
||||||
|
threshold: null
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: architecture-enforcement:write-repository-capability
|
||||||
|
|
||||||
|
# source: feature-repository-access-permission-contract — decisions 2026-05-22
|
||||||
|
# "SENSITIVE_READ marker = registry-managed metadata table (entity FQN + field name 단위)"
|
||||||
|
- name: SENSITIVE_READ
|
||||||
|
scope: use_case_method
|
||||||
|
enforcement: archunit
|
||||||
|
annotation: "@UseCaseCapability(sensitiveRead = true)"
|
||||||
|
semantics: "PII/credential 등 sensitive field를 읽는 use case가 선언해야 하는 capability. marker는 registry-managed metadata table(entity FQN + field name 단위)에서 lookup. domain annotation 또는 JPA entity annotation 형태는 forbidden(domain에 framework 의존 회피). pseudonymized data read는 documented 시에만 예외 허용."
|
||||||
|
owner_branch: feature-repository-access-permission-contract
|
||||||
|
bound_to_capability: null
|
||||||
|
threshold: null
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: architecture-enforcement:sensitive-read-capability
|
||||||
|
|
||||||
|
# source: feature-repository-access-permission-contract — decisions 2026-05-22
|
||||||
|
# "BULK_WRITE threshold = N > 100 또는 batch size > 100. 미만은 일반 WRITE_REPOSITORY로 충분"
|
||||||
|
- name: BULK_WRITE
|
||||||
|
scope: use_case_method
|
||||||
|
enforcement: archunit
|
||||||
|
annotation: "@UseCaseCapability(bulkWrite = true)"
|
||||||
|
semantics: "단일 transaction 내 N > 100 또는 batch size > 100 mutating operation을 수행하는 use case가 선언해야 하는 capability. 이 미만이면 일반 WRITE_REPOSITORY로 충분. lock 점유 시간, pool 영향, retry 비용이 큰 작업을 명시화."
|
||||||
|
owner_branch: feature-repository-access-permission-contract
|
||||||
|
bound_to_capability: WRITE_REPOSITORY
|
||||||
|
threshold: 100
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: architecture-enforcement:bulk-write-capability
|
||||||
|
|
||||||
|
# source: feature-repository-access-permission-contract — decisions 2026-05-22
|
||||||
|
# "TRANSACTION_REQUIRED는 application-port branch의 TransactionPort contract와 연결되어야 하며 Spring @Transactional 직접 import로 충족하지 않음"
|
||||||
|
# source: feature-application-port-usecase-contract — TransactionPort Contract
|
||||||
|
- name: TRANSACTION_REQUIRED
|
||||||
|
scope: use_case_method
|
||||||
|
enforcement: archunit
|
||||||
|
annotation: "@UseCaseCapability(transactionMode = WRITE | READ_ONLY | REQUIRES_NEW)"
|
||||||
|
semantics: "use case가 TransactionPort(또는 TransactionalUseCaseRunner)를 통해 transactional boundary를 갖는 것을 강제. Spring @Transactional의 application package 직접 import는 forbidden. infrastructure가 Spring transaction implementation을 제공하고 application은 port만 호출."
|
||||||
|
owner_branch: feature-repository-access-permission-contract
|
||||||
|
bound_to_capability: null
|
||||||
|
threshold: null
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: architecture-enforcement:transaction-required-capability
|
||||||
|
|
||||||
|
# source: feature-repository-access-permission-contract — decisions 2026-05-22
|
||||||
|
# "EXTERNAL_OUTBOUND_ALLOWED 분류 = outbox row INSERT는 in-process(불요), polling publisher의 broker publish는 outbound(필요)"
|
||||||
|
# source: feature-application-port-usecase-contract — "outbound adapter 호출 use case에 EXTERNAL_OUTBOUND_ALLOWED가 없으면 실패"
|
||||||
|
- name: EXTERNAL_OUTBOUND_ALLOWED
|
||||||
|
scope: use_case_method
|
||||||
|
enforcement: archunit
|
||||||
|
annotation: "@UseCaseCapability(externalOutboundAllowed = true)"
|
||||||
|
semantics: "use case가 외부 HTTP/message broker로 outbound 호출을 발생시키는 것을 허용. outbox claim 분류: outbox row INSERT는 in-process이므로 본 capability 불요. polling publisher의 broker publish는 outbound이므로 필요. domain event without transport detail은 outbound 호출이 아니므로 별도 분류."
|
||||||
|
owner_branch: feature-repository-access-permission-contract
|
||||||
|
bound_to_capability: null
|
||||||
|
threshold: null
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: architecture-enforcement:external-outbound-capability
|
||||||
|
|
||||||
|
# source: feature-repository-access-permission-contract — decisions 2026-05-22
|
||||||
|
# "CROSS_TENANT_ADMIN capability를 capability vocabulary에 추가 (tenant branch feature-tenant-context-policy와 cross-link)"
|
||||||
|
- name: CROSS_TENANT_ADMIN
|
||||||
|
scope: use_case_method
|
||||||
|
enforcement: archunit
|
||||||
|
annotation: "@UseCaseCapability(crossTenantAdmin = true)"
|
||||||
|
semantics: "tenant 경계를 넘어 데이터에 접근/변경하는 admin use case가 선언해야 하는 capability. tenant-context-policy의 cross-tenant 정책과 cross-link되어야 하며, 단일 tenant 범위 use case에서 이 capability를 선언하면 review에서 reject. SENSITIVE_READ가 동반될 가능성이 높지만 자동 결합은 아님."
|
||||||
|
owner_branch: feature-repository-access-permission-contract
|
||||||
|
bound_to_capability: null
|
||||||
|
threshold: null
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: architecture-enforcement:cross-tenant-admin-capability
|
||||||
|
|
||||||
|
# Row count verification
|
||||||
|
# - feature-repository-access-permission-contract 판정 기준 "Required capability" 표에 명시된 7개:
|
||||||
|
# READ_REPOSITORY, WRITE_REPOSITORY, SENSITIVE_READ, BULK_WRITE, TRANSACTION_REQUIRED,
|
||||||
|
# EXTERNAL_OUTBOUND_ALLOWED, CROSS_TENANT_ADMIN.
|
||||||
|
# - source에 명시되지 않은 capability는 본 registry에 추가하지 않음 (추측 금지).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,918 @@
|
|||||||
|
# Registry: Error Codes
|
||||||
|
# SSOT: wiki/projects/ca-tmpl/registries/error-codes.yaml
|
||||||
|
# Schema owner: feature-contract-registry-governance
|
||||||
|
# Category enum owner: feature-operational-error-observability-foundation
|
||||||
|
# Last updated: 2026-05-22
|
||||||
|
# Note: 이 파일은 Phase B 산출물. Phase C2(ca-tmpl 실 코드)에서 generated Java constants의 source.
|
||||||
|
#
|
||||||
|
# Schema (per row):
|
||||||
|
# code: UPPER_SNAKE_CASE
|
||||||
|
# category: VALIDATION | AUTH | AUTHZ | NOT_FOUND | CONFLICT |
|
||||||
|
# RATE_LIMIT | TRANSIENT_DEPENDENCY | PERMANENT_DEPENDENCY |
|
||||||
|
# DATA_INTEGRITY | INTERNAL
|
||||||
|
# http_status: int (async-only failures use 500 placeholder)
|
||||||
|
# retryable: bool
|
||||||
|
# retry_after_seconds: int | null (RATE_LIMIT/TRANSIENT 권고 backoff)
|
||||||
|
# owner_branch: source branch (raw/branch-notes/feature-*.md)
|
||||||
|
# owner_layer: presentation | application | domain | infrastructure | crosscut
|
||||||
|
# client_safe_message: no token / no principal raw / no internal path / no stack trace
|
||||||
|
# log_level: ERROR | WARN | INFO
|
||||||
|
# runbook_link: runbook://area/scenario OR null (client-error만 null 허용)
|
||||||
|
# compatibility_impact: none | additive | behavior-change | breaking
|
||||||
|
# required_test: owning contract test identifier
|
||||||
|
#
|
||||||
|
# Runbook policy (operational-runbook-contract L80):
|
||||||
|
# retryable=false + category ∈ {AUTH, AUTHZ, RATE_LIMIT, INTERNAL,
|
||||||
|
# TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY} ⇒ runbook_link 필수.
|
||||||
|
# VALIDATION/NOT_FOUND/CONFLICT/DATA_INTEGRITY는 client-error로 runbook 면제 가능.
|
||||||
|
# retryable=true 인 모든 row는 runbook_link 필수.
|
||||||
|
|
||||||
|
errors:
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# AUTH (feature-security-operational-baseline / Decision Matrix)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L82 — "token 누락 | 401 | AUTH_TOKEN_MISSING | AUTH"
|
||||||
|
- code: AUTH_TOKEN_MISSING
|
||||||
|
category: AUTH
|
||||||
|
http_status: 401
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Authentication required"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://auth/token-missing"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:auth-category
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L83 — "token malformed (parse fail) | 401 | AUTH_TOKEN_MALFORMED | AUTH"
|
||||||
|
- code: AUTH_TOKEN_MALFORMED
|
||||||
|
category: AUTH
|
||||||
|
http_status: 401
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Authentication failed"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://auth/token-malformed"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:auth-category
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L84 — "token expired (clock skew tolerance 60s 초과) | 401 | AUTH_TOKEN_EXPIRED | AUTH"
|
||||||
|
- code: AUTH_TOKEN_EXPIRED
|
||||||
|
category: AUTH
|
||||||
|
http_status: 401
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Authentication expired"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://auth/token-expired"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:auth-category
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L85 — "invalid signature | 401 | AUTH_TOKEN_INVALID_SIGNATURE | AUTH"
|
||||||
|
- code: AUTH_TOKEN_INVALID_SIGNATURE
|
||||||
|
category: AUTH
|
||||||
|
http_status: 401
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Authentication failed"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://auth/token-invalid-signature"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:auth-category
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L86 — "issuer mismatch | 401 | AUTH_ISSUER_MISMATCH | AUTH"
|
||||||
|
- code: AUTH_ISSUER_MISMATCH
|
||||||
|
category: AUTH
|
||||||
|
http_status: 401
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Authentication failed"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://auth/issuer-mismatch"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:auth-category
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L87 — "audience mismatch | 401 | AUTH_AUDIENCE_MISMATCH | AUTH"
|
||||||
|
- code: AUTH_AUDIENCE_MISMATCH
|
||||||
|
category: AUTH
|
||||||
|
http_status: 401
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Authentication failed"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://auth/audience-mismatch"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:auth-category
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L88 — "unknown kid (JWKS 미캐시) | 401 + Retry-After 5s | AUTH_KID_UNKNOWN | AUTH"
|
||||||
|
- code: AUTH_KID_UNKNOWN
|
||||||
|
category: AUTH
|
||||||
|
http_status: 401
|
||||||
|
retryable: true # 2026-06-01: false→true. JWKS 키 회전 중 unknown kid 는 ~5s 후 JWKS refresh 로 해소 가능(transient). retry_after_seconds=5 + client_safe_message "please retry" 와 정합. 키 고정 정책으로 전환 시 false 복귀.
|
||||||
|
retry_after_seconds: 5
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Authentication failed, please retry"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://auth/kid-unknown"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:auth-category
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L89 — "JWKS endpoint outage ... | AUTH_JWKS_UNAVAILABLE | TRANSIENT_DEPENDENCY"
|
||||||
|
- code: AUTH_JWKS_UNAVAILABLE
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 30
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Authentication service temporarily unavailable"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://auth/jwks-unavailable"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:auth-category
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L90 — "claim mapping failure ... | 401 | AUTH_CLAIM_MAPPING_FAILED | AUTH"
|
||||||
|
- code: AUTH_CLAIM_MAPPING_FAILED
|
||||||
|
category: AUTH
|
||||||
|
http_status: 401
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Authentication failed"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://auth/claim-mapping-failed"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:auth-category
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# AUTHZ (feature-security-operational-baseline)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L91 — "valid token + 권한 부족 | 403 | AUTHZ_INSUFFICIENT_PERMISSION | AUTHZ"
|
||||||
|
- code: AUTHZ_INSUFFICIENT_PERMISSION
|
||||||
|
category: AUTHZ
|
||||||
|
http_status: 403
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Permission denied"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://authz/insufficient-permission"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:authz-category
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L92 — "valid token + tenant cross-access | 403 | AUTHZ_TENANT_MISMATCH | AUTHZ"
|
||||||
|
- code: AUTHZ_TENANT_MISMATCH
|
||||||
|
category: AUTHZ
|
||||||
|
http_status: 403
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Permission denied"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://authz/tenant-mismatch"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:authz-category
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# INTERNAL (feature-security-operational-baseline + container-runtime)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline L93 — "public path misconfiguration ... | 500 + P1 alert | INTERNAL_AUTH_MISCONFIGURATION | INTERNAL"
|
||||||
|
- code: INTERNAL_AUTH_MISCONFIGURATION
|
||||||
|
category: INTERNAL
|
||||||
|
http_status: 500
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
owner_layer: crosscut
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://auth/public-path-misconfiguration"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:auth-category
|
||||||
|
|
||||||
|
# source: feature-container-runtime-contract L113 — "JVM OutOfMemoryError → ExitOnOutOfMemoryError로 137 exit, log에 error.code=JVM_OOM 명시"
|
||||||
|
- code: JVM_OOM
|
||||||
|
category: INTERNAL
|
||||||
|
http_status: 500
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-container-runtime-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://runtime/jvm-oom"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:container-runtime-oom
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# DB / Persistence (feature-persistence-failure-baseline / SQLState Matrix)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline L85 — "08* | all | TRANSIENT_DEPENDENCY | DB_UNAVAILABLE | true"
|
||||||
|
- code: DB_UNAVAILABLE
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 5
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Service temporarily unavailable"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://db/unavailable"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:persistence-mapping
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline L86 — "40001 | Postgres/MySQL | CONFLICT | DB_SERIALIZATION_FAILURE | true"
|
||||||
|
- code: DB_SERIALIZATION_FAILURE
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 1
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Request conflicted with another transaction, please retry"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://db/serialization-failure"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:persistence-mapping
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline L87 — "40P01 | Postgres | CONFLICT | DB_DEADLOCK | true (backoff)"
|
||||||
|
- code: DB_DEADLOCK
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 1
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Request conflicted, please retry"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://db/deadlock"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:persistence-mapping
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline L88 — "23502 | Postgres | DATA_INTEGRITY | DB_NULL_VIOLATION | false"
|
||||||
|
- code: DB_NULL_VIOLATION
|
||||||
|
category: DATA_INTEGRITY
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Request violates a required field constraint"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:persistence-mapping
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline L89 — "23503 | Postgres | DATA_INTEGRITY | DB_FK_VIOLATION | false"
|
||||||
|
- code: DB_FK_VIOLATION
|
||||||
|
category: DATA_INTEGRITY
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Request references missing resource"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:persistence-mapping
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline L90 — "23505 | Postgres | CONFLICT | DB_UNIQUE_VIOLATION | false (business mapping)"
|
||||||
|
- code: DB_UNIQUE_VIOLATION
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Resource already exists"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:persistence-mapping
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline L91 — "23514 | Postgres | DATA_INTEGRITY | DB_CHECK_VIOLATION | false"
|
||||||
|
- code: DB_CHECK_VIOLATION
|
||||||
|
category: DATA_INTEGRITY
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Request violates a value constraint"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:persistence-mapping
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline L92 — "25P03 | Postgres | TRANSIENT_DEPENDENCY | DB_IDLE_IN_TX_TIMEOUT | true"
|
||||||
|
- code: DB_IDLE_IN_TX_TIMEOUT
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 2
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Service temporarily unavailable"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://db/idle-in-tx-timeout"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:persistence-mapping
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline L93 — "57014 | Postgres | TRANSIENT_DEPENDENCY | DB_QUERY_CANCELED | false"
|
||||||
|
- code: DB_QUERY_CANCELED
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Request was canceled, please retry later"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://db/query-canceled"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:persistence-mapping
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Rate limit / Idempotency (feature-rate-limit-idempotency-contract)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-rate-limit-idempotency-contract — rate limit response/log 기준 / Retry-After header 기준 (scope L29, L33)
|
||||||
|
- code: RATE_LIMIT_EXCEEDED
|
||||||
|
category: RATE_LIMIT
|
||||||
|
http_status: 429
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 1
|
||||||
|
owner_branch: feature-rate-limit-idempotency-contract
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Too many requests, please retry after the indicated interval"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://rate-limit/exceeded"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:rate-limit
|
||||||
|
|
||||||
|
# source: feature-rate-limit-idempotency-contract L71 — "200ms 초과 시 409 IDEMPOTENT_IN_FLIGHT (retryable=false, client는 polling)"
|
||||||
|
- code: IDEMPOTENT_IN_FLIGHT
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-rate-limit-idempotency-contract
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "A previous identical request is still being processed, please poll for result"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:idempotency
|
||||||
|
|
||||||
|
# source: feature-rate-limit-idempotency-contract L72 — "fingerprint mismatch (same key + different body) = 422 IDEMPOTENT_REQUEST_MISMATCH"
|
||||||
|
- code: IDEMPOTENT_REQUEST_MISMATCH
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 422
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-rate-limit-idempotency-contract
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "Idempotency key reused with different request body"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:idempotency
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# File / Resource (feature-file-resource-handling-contract)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-file-resource-handling-contract L69 — "spring.servlet.multipart.max-file-size 10MB ... Spring 단의 enforcement가 실패 시 envelope 응답 보장" / 테스트 계약 "oversized upload가 generic 500으로 처리되면 실패"
|
||||||
|
- code: UPLOAD_SIZE_EXCEEDED
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 413
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-file-resource-handling-contract
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Uploaded file exceeds maximum size"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:file-upload
|
||||||
|
|
||||||
|
# source: feature-file-resource-handling-contract L72 — "allowed content-type allowlist starting set ..."
|
||||||
|
- code: UPLOAD_CONTENT_TYPE_REJECTED
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 415
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-file-resource-handling-contract
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Uploaded content type is not allowed"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:file-upload
|
||||||
|
|
||||||
|
# source: feature-file-resource-handling-contract — Decisionized Work Items "path traversal | normalized storage key only ... | traversal test"
|
||||||
|
- code: PATH_TRAVERSAL_DETECTED
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 400
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-file-resource-handling-contract
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Invalid file path"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:file-upload
|
||||||
|
|
||||||
|
# source: feature-file-resource-handling-contract L73 — "streaming download backpressure = response timeout 60s, max stream 100MB. 초과 시 truncate + ERROR log"
|
||||||
|
- code: DOWNLOAD_STREAMING_FAILURE
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 5
|
||||||
|
owner_branch: feature-file-resource-handling-contract
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Download failed, please retry"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://file/download-streaming-failure"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:file-download
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API contract transport-standard codes (feature-api-contract-baseline)
|
||||||
|
# ============================================================
|
||||||
|
# NOTE: feature-api-contract-baseline owns the transport-shape failure
|
||||||
|
# classification (D8 413/414, D9 406/415, D12 405, D15 412). These rows mirror
|
||||||
|
# dev.caskeleton.shared.error.OperationalError; the D11 status-mapping
|
||||||
|
# consistency test (owner: this branch, producer) fails the build when a code's
|
||||||
|
# registry http_status and the enum httpStatus() drift apart.
|
||||||
|
|
||||||
|
# source: feature-api-contract-baseline.md D12 — "405 Method Not Allowed + Allow header 의무"
|
||||||
|
- code: METHOD_NOT_ALLOWED
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 405
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-api-contract-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "HTTP method not allowed for this resource"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:api-contract-status-mapping
|
||||||
|
|
||||||
|
# source: feature-api-contract-baseline.md D9 — "406 Not Acceptable = 응답 표현 협상 실패"
|
||||||
|
- code: NOT_ACCEPTABLE
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 406
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-api-contract-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "No acceptable representation for the requested Accept header"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:api-contract-status-mapping
|
||||||
|
|
||||||
|
# source: feature-api-contract-baseline.md D15 — "If-Match mismatch 시 412 Precondition Failed"
|
||||||
|
- code: PRECONDITION_FAILED
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 412
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-api-contract-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Resource was modified by another request; refetch and retry"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:api-contract-status-mapping
|
||||||
|
|
||||||
|
# source: feature-api-contract-baseline.md D8 — "request size limit 실패 분류 (413)"
|
||||||
|
- code: PAYLOAD_TOO_LARGE
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 413
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-api-contract-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Request payload is too large"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:api-contract-status-mapping
|
||||||
|
|
||||||
|
# source: feature-api-contract-baseline.md D8 형제 — "URI 길이 실패 분류 (414)"
|
||||||
|
# NOTE: enforcement is Tomcat/gateway-owned (rejected before Spring dispatch);
|
||||||
|
# this row + code exist for status-mapping consistency. End-to-end 414 contract
|
||||||
|
# test is `planned` (gateway/Tomcat maxHttpHeaderSize 8KB boundary).
|
||||||
|
- code: URI_TOO_LONG
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 414
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-api-contract-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Request URI is too long"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:api-contract-status-mapping
|
||||||
|
|
||||||
|
# source: feature-api-contract-baseline.md D9 — "415 Unsupported Media Type = 요청 본문 format 미지원"
|
||||||
|
- code: UNSUPPORTED_MEDIA_TYPE
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 415
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-api-contract-baseline
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Request Content-Type is not supported"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:api-contract-status-mapping
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Tenant (feature-tenant-context-policy)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-tenant-context-policy L71 — "tenant 미지원 모드에서 X-Tenant-Id 헤더 수신 시 400 TENANT_NOT_SUPPORTED (filter 단계)"
|
||||||
|
- code: TENANT_NOT_SUPPORTED
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 400
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-tenant-context-policy
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Tenant context is not supported by this deployment"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:tenant-policy
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Validation / Business rule (feature-business-rule-validation-contract)
|
||||||
|
# ============================================================
|
||||||
|
# NOTE: business-rule-validation branch는 mapping 규칙 SSOT (syntax→VALIDATION,
|
||||||
|
# policy→AUTHZ/CONFLICT, invariant→CONFLICT/VALIDATION, persistence→PERSISTENCE/CONFLICT)
|
||||||
|
# 이며 구체 code는 example로 VALIDATION_EMAIL_FORMAT만 등장
|
||||||
|
# (feature-operational-error-observability-foundation L110). 실제 도메인별 code는
|
||||||
|
# Phase D(도메인 feature 적용) 시 본 registry에 추가.
|
||||||
|
|
||||||
|
# source: feature-operational-error-observability-foundation L110 — "code: VALIDATION_EMAIL_FORMAT, // registry-registered code" (validation field error JSON shape example)
|
||||||
|
- code: VALIDATION_EMAIL_FORMAT
|
||||||
|
category: VALIDATION
|
||||||
|
http_status: 400
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-operational-error-observability-foundation
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Invalid email format"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:validation-envelope
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Cache (feature-cache-consistency-contract)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-cache-consistency-contract — Decisionized Work Items "Redis unavailable | degrade only if declared | fail-fast for required cache | generic INTERNAL | unavailable mapping" / 테스트 "Redis unavailable이 degrade 가능 여부 없이 INTERNAL로 처리되면 실패"
|
||||||
|
- code: CACHE_UNAVAILABLE
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 2
|
||||||
|
owner_branch: feature-cache-consistency-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Service temporarily unavailable"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://cache/unavailable"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:cache-consistency
|
||||||
|
|
||||||
|
# source: feature-cache-consistency-contract L70 — "stampede 방지 default = single-instance Caffeine local lock, multi-instance HPA 시 Redisson RLock distributed mutex" / 테스트 "동일 key에 대해 동시 cache miss 시 backend 호출이 1회로 제한되는지 verify (stampede). 미충족 시 실패"
|
||||||
|
- code: CACHE_STAMPEDE_LOCK_TIMEOUT
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 1
|
||||||
|
owner_branch: feature-cache-consistency-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Service temporarily unavailable"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://cache/stampede-lock-timeout"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:cache-consistency
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Outbound HTTP (feature-outbound-http-client-baseline)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-outbound-http-client-baseline L70 — "outbound HTTP timeout default = connect 2s / read 5s / global call 10s" + scope "timeout/connect/DNS failure 분류" / 테스트 "upstream timeout은 retryable dependency failure로 분류되어야 함"
|
||||||
|
- code: DEPENDENCY_TIMEOUT
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 504
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 2
|
||||||
|
owner_branch: feature-outbound-http-client-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Upstream service did not respond in time, please retry"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://dependency/timeout"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:outbound-http
|
||||||
|
|
||||||
|
# source: feature-outbound-http-client-baseline — scope "timeout/connect/DNS failure 분류" + L70 connect=2s timeout
|
||||||
|
- code: DEPENDENCY_CONNECT_FAILED
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 2
|
||||||
|
owner_branch: feature-outbound-http-client-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Upstream service unreachable, please retry"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://dependency/connect-failed"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:outbound-http
|
||||||
|
|
||||||
|
# source: feature-outbound-http-client-baseline — scope "timeout/connect/DNS failure 분류"
|
||||||
|
- code: DEPENDENCY_DNS_FAILED
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 5
|
||||||
|
owner_branch: feature-outbound-http-client-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Upstream service unreachable, please retry"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://dependency/dns-failed"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:outbound-http
|
||||||
|
|
||||||
|
# source: feature-outbound-http-client-baseline — scope "upstream 4xx/5xx 분류" / 테스트 "401/403은 credential/scope/config 문제로 분류되어야 함"
|
||||||
|
- code: DEPENDENCY_4XX_CLIENT
|
||||||
|
category: PERMANENT_DEPENDENCY
|
||||||
|
http_status: 502
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-outbound-http-client-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Upstream service rejected the request"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://dependency/4xx-client"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:outbound-http
|
||||||
|
|
||||||
|
# source: feature-outbound-http-client-baseline — scope "upstream 4xx/5xx 분류"
|
||||||
|
- code: DEPENDENCY_5XX_SERVER
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 502
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 2
|
||||||
|
owner_branch: feature-outbound-http-client-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Upstream service error, please retry"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://dependency/5xx-server"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:outbound-http
|
||||||
|
|
||||||
|
# source: feature-outbound-http-client-baseline L69 — "circuit breaker metric은 dependency.name, dependency.type, outcome까지만 tag로 허용" + Decisionized "circuit breaker | Resilience4j optional env"
|
||||||
|
- code: DEPENDENCY_CIRCUIT_OPEN
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 10
|
||||||
|
owner_branch: feature-outbound-http-client-baseline
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Upstream service temporarily unavailable, please retry later"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://dependency/circuit-open"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:outbound-http
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Outbox (feature-domain-event-outbox-contract)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-domain-event-outbox-contract L67 — "outbox row status enum = PENDING / IN_FLIGHT / PUBLISHED / FAILED / DEAD" + scope "publish 실패 분류" / 판정 "publish 실패가 retry/DLQ/log/runbook 기준 없이 삼켜지면 실패"
|
||||||
|
- code: OUTBOX_PUBLISH_FAILED
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 500
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 30
|
||||||
|
owner_branch: feature-domain-event-outbox-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://outbox/publish-failed"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:outbox-publish
|
||||||
|
|
||||||
|
# source: feature-domain-event-outbox-contract L67 — outbox status enum "DEAD" / Outbox Defaults "DLQ | background-job branch owner"
|
||||||
|
- code: OUTBOX_DEAD_LETTER
|
||||||
|
category: INTERNAL
|
||||||
|
http_status: 500
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-domain-event-outbox-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://outbox/dead-letter"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:outbox-dlq
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Background job / Async (feature-background-job-async-contract)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-background-job-async-contract — Decisionized "saturation | bounded executor + rejection log" / L72 "saturation policy default = AbortPolicy" / 테스트 "executor rejection이 structured log 없이 발생하면 실패"
|
||||||
|
- code: JOB_EXECUTOR_REJECTED
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 503
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 5
|
||||||
|
owner_branch: feature-background-job-async-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Service temporarily unavailable"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://job/executor-rejected"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:async-saturation
|
||||||
|
|
||||||
|
# source: feature-background-job-async-contract L69 — "기본 backoff는 exponential backoff with jitter, max attempts 3, DLQ after exhausted attempts" + scope "shutdown 중 job 처리 기준" / L73 graceful shutdown ≤19s
|
||||||
|
- code: JOB_TIMEOUT
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
http_status: 500
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 10
|
||||||
|
owner_branch: feature-background-job-async-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://job/timeout"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:async-timeout
|
||||||
|
|
||||||
|
# source: feature-background-job-async-contract L69 — "DLQ after exhausted attempts" + Decisionized "retry/DLQ | exp backoff jitter, max 3, DLQ exhausted | ... | infinite retry"
|
||||||
|
- code: JOB_DEAD_LETTER
|
||||||
|
category: INTERNAL
|
||||||
|
http_status: 500
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-background-job-async-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://job/dead-letter"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:async-dlq
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Distributed Lock (feature-distributed-lock-contract)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-distributed-lock-contract D7 — "lock 획득 실패/timeout 의 error code =
|
||||||
|
# LOCK_ACQUISITION_TIMEOUT (category CONFLICT, retryable true, client_safe true) + metric
|
||||||
|
# lock.acquisition" / D5 — "try-lock + 유한 waitTime + lease(TTL) 필수, 무한 blocking 금지".
|
||||||
|
# category CONFLICT 는 기존 enum 재사용; retryable=true — 락 보유자가 임계 구역을 빠져나오면
|
||||||
|
# 동일 요청 재시도로 해소된다(transient contention). DB_DEADLOCK / DB_SERIALIZATION_FAILURE 와
|
||||||
|
# 같은 retryable CONFLICT 계열(409). 본 코드는 distributedLockProvider 획득 timeout 전용이며
|
||||||
|
# cache stampede lock 의 CACHE_STAMPEDE_LOCK_TIMEOUT(cache-consistency, TRANSIENT_DEPENDENCY 503)
|
||||||
|
# 과 의미가 구분된다 — 후자는 캐시 백엔드 의존성 timeout, 전자는 분산 상호배제 contention.
|
||||||
|
- code: LOCK_ACQUISITION_TIMEOUT
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: true
|
||||||
|
retry_after_seconds: 1
|
||||||
|
owner_branch: feature-distributed-lock-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Resource is busy, please retry"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://lock/acquisition-timeout"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:lock-acquisition-timeout
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Migration / Startup (feature-migration-startup-contract)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... migration 실패=70 ..." + Decisionized "startup failure log | structured log with startup.phase, error.code, error.category"
|
||||||
|
- code: MIGRATION_FAILED
|
||||||
|
category: INTERNAL
|
||||||
|
http_status: 500
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-migration-startup-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://migration/failed"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:migration-startup
|
||||||
|
|
||||||
|
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = env 누락/malformed=78 ..." / 테스트 "required env 누락 시 startup이 성공하면 실패"
|
||||||
|
- code: STARTUP_VALIDATION_FAILED
|
||||||
|
category: INTERNAL
|
||||||
|
http_status: 500
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-migration-startup-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://startup/validation-failed"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:migration-startup
|
||||||
|
|
||||||
|
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... required adapter disabled=72" / 테스트 "disabled required adapter로 app이 뜨면 실패"
|
||||||
|
- code: REQUIRED_ADAPTER_DISABLED
|
||||||
|
category: INTERNAL
|
||||||
|
http_status: 500
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-migration-startup-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://startup/required-adapter-disabled"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:migration-startup
|
||||||
|
|
||||||
|
# source: feature-integration-adapter-templates §구현 가이드 §4 (Layer 3) + §Audit A2.
|
||||||
|
# Runtime-lifecycle fail-fast for an invoke against a DISABLED optional adapter
|
||||||
|
# (Kafka/Redis/Slack/Google Email). Deliberately distinct from the startup-lifecycle
|
||||||
|
# REQUIRED_ADAPTER_DISABLED above (exit 72): a runtime invoke ≠ a startup validation,
|
||||||
|
# so reusing the startup code would conflate two lifecycles (A2 resolution — new
|
||||||
|
# runtime code owned by this branch). retryable=false: the adapter stays disabled
|
||||||
|
# until redeploy, so retrying the same call never clears it.
|
||||||
|
- code: ADAPTER_DISABLED
|
||||||
|
category: INTERNAL
|
||||||
|
http_status: 500
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-integration-adapter-templates
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://adapter/adapter-disabled"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: adapter-contract:adapter-disabled-runtime-call
|
||||||
|
|
||||||
|
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... profile mismatch=71" / 테스트 "prod profile에서 local-only 설정이 켜지면 실패"
|
||||||
|
- code: PROFILE_MISMATCH
|
||||||
|
category: INTERNAL
|
||||||
|
http_status: 500
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-migration-startup-contract
|
||||||
|
owner_layer: infrastructure
|
||||||
|
client_safe_message: "Internal server error"
|
||||||
|
log_level: ERROR
|
||||||
|
runbook_link: "runbook://startup/profile-mismatch"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:migration-startup
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Management / Actuator (feature-management-actuator-security-contract)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# source: feature-management-actuator-security-contract — Exposure Policy "env/configprops | forbidden" "heapdump/threaddump | forbidden unless break-glass runbook" "shutdown | forbidden" / 테스트 "prod에서 env/configprops endpoint가 노출되면 실패"
|
||||||
|
- code: ACTUATOR_FORBIDDEN
|
||||||
|
category: AUTHZ
|
||||||
|
http_status: 403
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-management-actuator-security-contract
|
||||||
|
owner_layer: presentation
|
||||||
|
client_safe_message: "Permission denied"
|
||||||
|
log_level: WARN
|
||||||
|
runbook_link: "runbook://management/actuator-forbidden"
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:management-actuator
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# Registry: HTTP Headers
|
||||||
|
# SSOT: wiki/projects/ca-tmpl/registries/headers.yaml
|
||||||
|
# Schema owner: feature-contract-registry-governance
|
||||||
|
# Last updated: 2026-05-22
|
||||||
|
#
|
||||||
|
# Conventions:
|
||||||
|
# - HTTP header name: kebab-case (X-Request-Id, X-Tenant-Id)
|
||||||
|
# - W3C standard headers: lowercase (traceparent, tracestate)
|
||||||
|
# - mdc_key: snake_case (foundation SSOT)
|
||||||
|
# - envelope_meta_field: camelCase (envelope SSOT)
|
||||||
|
|
||||||
|
headers:
|
||||||
|
# source: feature-operational-error-observability-foundation.md L97
|
||||||
|
# "request_id | inbound filter (생성 또는 X-Request-Id 헤더) | response header X-Request-Id"
|
||||||
|
- name: X-Request-Id
|
||||||
|
direction: both
|
||||||
|
type: ulid
|
||||||
|
required: false
|
||||||
|
generated_if_missing: true
|
||||||
|
mdc_key: request_id
|
||||||
|
envelope_meta_field: requestId
|
||||||
|
owner_branch: feature-operational-error-observability-foundation
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:envelope-headers
|
||||||
|
|
||||||
|
# source: feature-api-contract-baseline.md L67
|
||||||
|
# "X-Api-Version은 실험/compatibility 보조 header이며 path version과 충돌하면 path가 우선"
|
||||||
|
- name: X-Api-Version
|
||||||
|
direction: inbound
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
generated_if_missing: false
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-api-contract-baseline
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:api-versioning
|
||||||
|
|
||||||
|
# source: feature-api-contract-baseline.md L77 / feature-rate-limit-idempotency-contract.md L66-67
|
||||||
|
# "idempotency header 이름은 Idempotency-Key" / "기본 scope는 (authenticatedPrincipal, idempotencyKey, useCaseName)"
|
||||||
|
- name: Idempotency-Key
|
||||||
|
direction: inbound
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
generated_if_missing: false
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-rate-limit-idempotency-contract
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:idempotency-replay
|
||||||
|
|
||||||
|
# source: feature-rate-limit-idempotency-contract.md L85 / foundation L85
|
||||||
|
# "RATE_LIMIT | ... | 429 | true (Retry-After 이후)" / "retry-after 기준 없이 429를 반환하면 실패"
|
||||||
|
- name: Retry-After
|
||||||
|
direction: outbound
|
||||||
|
type: duration-seconds
|
||||||
|
required: false
|
||||||
|
generated_if_missing: false
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-rate-limit-idempotency-contract
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:rate-limit-headers
|
||||||
|
|
||||||
|
# source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준)
|
||||||
|
# rate-limit 응답 표면 (limit/remaining/reset 3종은 표준 rate-limit signaling)
|
||||||
|
- name: X-RateLimit-Limit
|
||||||
|
direction: outbound
|
||||||
|
type: numeric
|
||||||
|
required: false
|
||||||
|
generated_if_missing: true
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-rate-limit-idempotency-contract
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:rate-limit-headers
|
||||||
|
|
||||||
|
# source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준)
|
||||||
|
- name: X-RateLimit-Remaining
|
||||||
|
direction: outbound
|
||||||
|
type: numeric
|
||||||
|
required: false
|
||||||
|
generated_if_missing: true
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-rate-limit-idempotency-contract
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:rate-limit-headers
|
||||||
|
|
||||||
|
# source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준)
|
||||||
|
- name: X-RateLimit-Reset
|
||||||
|
direction: outbound
|
||||||
|
type: rfc3339-date
|
||||||
|
required: false
|
||||||
|
generated_if_missing: true
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-rate-limit-idempotency-contract
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:rate-limit-headers
|
||||||
|
|
||||||
|
# source: feature-api-compatibility-deprecation-contract.md L87
|
||||||
|
# "deprecation marker | OpenAPI deprecated: true + branch note | response header optional"
|
||||||
|
- name: Deprecation
|
||||||
|
direction: outbound
|
||||||
|
type: rfc3339-date
|
||||||
|
required: false
|
||||||
|
generated_if_missing: false
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-api-compatibility-deprecation-contract
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:deprecation-marker
|
||||||
|
|
||||||
|
# source: feature-api-compatibility-deprecation-contract.md L87
|
||||||
|
# "deprecation marker | OpenAPI deprecated: true + branch note | response header optional" (RFC 8594 Sunset)
|
||||||
|
- name: Sunset
|
||||||
|
direction: outbound
|
||||||
|
type: rfc3339-date
|
||||||
|
required: false
|
||||||
|
generated_if_missing: false
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-api-compatibility-deprecation-contract
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:deprecation-marker
|
||||||
|
|
||||||
|
# source: feature-distributed-tracing-contract.md L64, L85
|
||||||
|
# "propagation header는 W3C traceparent default" / "HTTP | traceparent, tracestate (W3C)"
|
||||||
|
- name: traceparent
|
||||||
|
direction: both
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
generated_if_missing: true
|
||||||
|
mdc_key: trace_id
|
||||||
|
envelope_meta_field: traceId
|
||||||
|
owner_branch: feature-distributed-tracing-contract
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:trace-propagation
|
||||||
|
|
||||||
|
# source: feature-distributed-tracing-contract.md L66, L85
|
||||||
|
# "propagation format = W3C traceparent + tracestate only. B3 propagation은 forbidden"
|
||||||
|
- name: tracestate
|
||||||
|
direction: both
|
||||||
|
type: comma-separated
|
||||||
|
required: false
|
||||||
|
generated_if_missing: false
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-distributed-tracing-contract
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:trace-propagation
|
||||||
|
|
||||||
|
# source: feature-operational-error-observability-foundation.md L100
|
||||||
|
# "correlation_id | inbound header X-Correlation-Id 또는 생성 | HTTP X-Correlation-Id, message header correlation_id"
|
||||||
|
- name: X-Correlation-Id
|
||||||
|
direction: both
|
||||||
|
type: ulid
|
||||||
|
required: false
|
||||||
|
generated_if_missing: true
|
||||||
|
mdc_key: correlation_id
|
||||||
|
envelope_meta_field: correlationId
|
||||||
|
owner_branch: feature-operational-error-observability-foundation
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:envelope-headers
|
||||||
|
|
||||||
|
# source: feature-tenant-context-policy.md L69, L101 (foundation)
|
||||||
|
# "tenant resolution 우선순위 = ... (2) 명시적 X-Tenant-Id 헤더 (admin/internal API only)" /
|
||||||
|
# "tenant_id | tenant context (활성 시) | downstream HTTP X-Tenant-Id (with allowlist)"
|
||||||
|
- name: X-Tenant-Id
|
||||||
|
direction: both
|
||||||
|
type: ulid
|
||||||
|
required: false
|
||||||
|
generated_if_missing: false
|
||||||
|
mdc_key: tenant_id
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-tenant-context-policy
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:tenant-header-policy
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline.md L66
|
||||||
|
# "JWT Resource Server를 baseline security model로 둠" (Bearer token via Authorization header)
|
||||||
|
- name: Authorization
|
||||||
|
direction: inbound
|
||||||
|
type: bearer-token
|
||||||
|
required: false
|
||||||
|
generated_if_missing: false
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:jwt-resource-server
|
||||||
|
|
||||||
|
# source: feature-security-operational-baseline.md L83-90 (AuthN/AuthZ Decision Matrix)
|
||||||
|
# 401 응답 시 WWW-Authenticate (Bearer realm/error) — Spring Security JWT Resource Server 표준 challenge header
|
||||||
|
- name: WWW-Authenticate
|
||||||
|
direction: outbound
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
generated_if_missing: true
|
||||||
|
mdc_key: null
|
||||||
|
envelope_meta_field: null
|
||||||
|
owner_branch: feature-security-operational-baseline
|
||||||
|
case_style: kebab
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:jwt-resource-server
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# Registry: MDC / Log Keys
|
||||||
|
# SSOT: wiki/projects/ca-tmpl/registries/mdc-keys.yaml
|
||||||
|
# Schema owner: feature-contract-registry-governance
|
||||||
|
# MDC SSOT: feature-operational-error-observability-foundation
|
||||||
|
# Last updated: 2026-05-22
|
||||||
|
#
|
||||||
|
# Conventions:
|
||||||
|
# - MDC key naming: snake_case (foundation L93 "snake_case 강제. camelCase / dot.case 금지.")
|
||||||
|
# - cardinality_safe_for_metric=true 인 key만 metric tag로 사용 가능
|
||||||
|
# - foundation L93-102 표 "MDC Key Standard (final)" 6개가 core SSOT
|
||||||
|
|
||||||
|
mdc_keys:
|
||||||
|
# source: feature-operational-error-observability-foundation.md L97
|
||||||
|
# "request_id | inbound filter (생성 또는 X-Request-Id 헤더) | response header X-Request-Id"
|
||||||
|
- key: request_id
|
||||||
|
type: ulid
|
||||||
|
source: inbound_filter
|
||||||
|
required_in: [request, dependency, security, application]
|
||||||
|
http_header_mapping: X-Request-Id
|
||||||
|
envelope_field: meta.requestId
|
||||||
|
propagation: [http, async, message]
|
||||||
|
owner_branch: feature-operational-error-observability-foundation
|
||||||
|
cardinality_safe_for_metric: false
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-mdc-keys
|
||||||
|
|
||||||
|
# source: feature-operational-error-observability-foundation.md L98
|
||||||
|
# "trace_id | Micrometer Tracing | W3C traceparent header"
|
||||||
|
- key: trace_id
|
||||||
|
type: string
|
||||||
|
source: observation_context
|
||||||
|
required_in: [request, dependency, application]
|
||||||
|
http_header_mapping: traceparent
|
||||||
|
envelope_field: meta.traceId
|
||||||
|
propagation: [http, async, message]
|
||||||
|
owner_branch: feature-operational-error-observability-foundation
|
||||||
|
cardinality_safe_for_metric: false
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-mdc-keys
|
||||||
|
|
||||||
|
# source: feature-operational-error-observability-foundation.md L99
|
||||||
|
# "span_id | Micrometer Tracing | W3C traceparent"
|
||||||
|
# NOTE: background-job-async-contract L71 "span_id는 Micrometer Observation context에서 자동 전파(MDC explicit copy 불필요)"
|
||||||
|
- key: span_id
|
||||||
|
type: string
|
||||||
|
source: observation_context
|
||||||
|
required_in: [request, dependency]
|
||||||
|
http_header_mapping: traceparent
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [http, async]
|
||||||
|
owner_branch: feature-operational-error-observability-foundation
|
||||||
|
cardinality_safe_for_metric: false
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-mdc-keys
|
||||||
|
|
||||||
|
# source: feature-operational-error-observability-foundation.md L100
|
||||||
|
# "correlation_id | inbound header X-Correlation-Id 또는 생성 | HTTP X-Correlation-Id, message header correlation_id"
|
||||||
|
- key: correlation_id
|
||||||
|
type: ulid
|
||||||
|
source: inbound_filter
|
||||||
|
required_in: [request, dependency, application]
|
||||||
|
http_header_mapping: X-Correlation-Id
|
||||||
|
envelope_field: meta.correlationId
|
||||||
|
propagation: [http, async, message]
|
||||||
|
owner_branch: feature-operational-error-observability-foundation
|
||||||
|
cardinality_safe_for_metric: false
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-mdc-keys
|
||||||
|
|
||||||
|
# source: feature-operational-error-observability-foundation.md L101 + feature-tenant-context-policy.md L70
|
||||||
|
# "tenant_id | tenant context (활성 시) | downstream HTTP X-Tenant-Id (with allowlist)" /
|
||||||
|
# "tenant ID format = opaque ULID (26 chars Crockford base32)"
|
||||||
|
# NOTE: tenant L73 "tenant_id ULID 원본은 metric tag에 직접 사용 금지"
|
||||||
|
- key: tenant_id
|
||||||
|
type: ulid
|
||||||
|
source: security_context
|
||||||
|
required_in: [request, dependency, security, audit]
|
||||||
|
http_header_mapping: X-Tenant-Id
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [http, async, message]
|
||||||
|
owner_branch: feature-tenant-context-policy
|
||||||
|
cardinality_safe_for_metric: false
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:tenant-leakage
|
||||||
|
|
||||||
|
# source: feature-operational-error-observability-foundation.md L102
|
||||||
|
# "user_principal | security context (pseudonymized only) | log only, headers forbidden"
|
||||||
|
- key: user_principal
|
||||||
|
type: string
|
||||||
|
source: security_context
|
||||||
|
required_in: [security, audit]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-operational-error-observability-foundation
|
||||||
|
cardinality_safe_for_metric: false
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-mdc-keys
|
||||||
|
|
||||||
|
# ── log type extensions (log-management-contract L101-109 "Log Type별 필수 필드") ──
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms"
|
||||||
|
# NOTE: application-port-usecase-contract / business 측 operation 식별자 (uri_template과 별도 application-set)
|
||||||
|
- key: operation
|
||||||
|
type: string
|
||||||
|
source: application_set
|
||||||
|
required_in: [application, dependency]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: true
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms"
|
||||||
|
- key: method
|
||||||
|
type: string
|
||||||
|
source: inbound_filter
|
||||||
|
required_in: [request]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: true
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms"
|
||||||
|
# NOTE: metrics L86 "status_code | 7 (1xx-5xx + ok/other)" — bounded
|
||||||
|
- key: status
|
||||||
|
type: numeric
|
||||||
|
source: inbound_filter
|
||||||
|
required_in: [request]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: true
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L105-106 "request | ... duration_ms" / "dependency | ... duration_ms"
|
||||||
|
- key: duration_ms
|
||||||
|
type: numeric
|
||||||
|
source: application_set
|
||||||
|
required_in: [request, dependency]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: false
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L106 "dependency | dependency_name, dependency_type, duration_ms, outcome, error_code"
|
||||||
|
# NOTE: metrics L88 "dependency_name | 50" — bounded
|
||||||
|
- key: dependency_name
|
||||||
|
type: string
|
||||||
|
source: application_set
|
||||||
|
required_in: [dependency]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: true
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L106 "dependency | dependency_name, dependency_type, ..."
|
||||||
|
- key: dependency_type
|
||||||
|
type: string
|
||||||
|
source: application_set
|
||||||
|
required_in: [dependency]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: true
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L106 + metrics L91 "outcome (resilience4j) | 5 (SUCCESS/FAILURE/CIRCUIT_OPEN/TIMEOUT/REJECTED)"
|
||||||
|
- key: outcome
|
||||||
|
type: string
|
||||||
|
source: application_set
|
||||||
|
required_in: [dependency]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: true
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L106 "dependency | ... error_code (실패 시)"
|
||||||
|
# NOTE: metrics L89 "error_code | 100 — error registry row 상한과 정합" — bounded
|
||||||
|
- key: error_code
|
||||||
|
type: string
|
||||||
|
source: application_set
|
||||||
|
required_in: [dependency]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: true
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L107
|
||||||
|
# "security | event_type, user_principal (pseudonymized), source_ip (anonymized — last octet zeroed)"
|
||||||
|
- key: event_type
|
||||||
|
type: string
|
||||||
|
source: application_set
|
||||||
|
required_in: [security, audit]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: true
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L107 "security | ... source_ip (anonymized — last octet zeroed)"
|
||||||
|
# NOTE: metrics L93 "high-cardinality 금지 tag: ... ip_address"
|
||||||
|
- key: source_ip_anon
|
||||||
|
type: string
|
||||||
|
source: inbound_filter
|
||||||
|
required_in: [security]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: false
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L108 "audit | actor, action, target, before_hash, after_hash, occurred_at"
|
||||||
|
- key: actor
|
||||||
|
type: string
|
||||||
|
source: security_context
|
||||||
|
required_in: [audit]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: false
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L108 "audit | actor, action, target, ..."
|
||||||
|
- key: action
|
||||||
|
type: string
|
||||||
|
source: application_set
|
||||||
|
required_in: [audit]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: true
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
|
|
||||||
|
# source: feature-log-management-contract.md L108 "audit | actor, action, target, ..."
|
||||||
|
- key: target
|
||||||
|
type: string
|
||||||
|
source: application_set
|
||||||
|
required_in: [audit]
|
||||||
|
http_header_mapping: null
|
||||||
|
envelope_field: null
|
||||||
|
propagation: [none]
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
cardinality_safe_for_metric: false
|
||||||
|
case_style: snake
|
||||||
|
compatibility_impact: none
|
||||||
|
required_test: contract-verification:log-fields
|
||||||
@@ -0,0 +1,845 @@
|
|||||||
|
# Registry: Metrics
|
||||||
|
# SSOT: wiki/projects/ca-tmpl/registries/metrics.yaml
|
||||||
|
# Schema owner: feature-contract-registry-governance
|
||||||
|
# Owner branch: feature-metrics-alerting-contract
|
||||||
|
# Last updated: 2026-05-22
|
||||||
|
#
|
||||||
|
# Notes
|
||||||
|
# - Naming: Micrometer dot.case + unit suffix (.seconds | .bytes | .total).
|
||||||
|
# - Tag cardinality bounds are SSOT of feature-metrics-alerting-contract "Cardinality Bounds" table.
|
||||||
|
# - High-cardinality tags forbidden globally: user_id, request_id, raw_url, raw_query,
|
||||||
|
# raw_header_value, ip_address. These MUST NOT appear in any row.
|
||||||
|
# - tenant_id label is bounded mapping table id OR cohort bucket only (ULID raw forbidden).
|
||||||
|
# - error_code tag cardinality_limit follows error-codes.yaml row count (max 100).
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
|
||||||
|
# === HTTP server (inbound) ===
|
||||||
|
# source: feature-metrics-alerting-contract — Metric/Alert Defaults
|
||||||
|
# "HTTP metric | http.server.requests with method/status/uri-template | raw URL or user id tag"
|
||||||
|
- name: http.server.requests
|
||||||
|
type: timer
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: method
|
||||||
|
cardinality_limit: 8
|
||||||
|
allowed_values: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, OTHER]
|
||||||
|
- name: status
|
||||||
|
cardinality_limit: 7
|
||||||
|
allowed_values: [1xx, 2xx, 3xx, 4xx, 5xx, ok, other]
|
||||||
|
- name: uri_template
|
||||||
|
cardinality_limit: 200
|
||||||
|
validation: must_be_template_not_raw_uri
|
||||||
|
percentiles: [0.5, 0.9, 0.95, 0.99]
|
||||||
|
histogram_buckets: slo_driven
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "error_rate > 5% for 5m OR > 10% for 1m"
|
||||||
|
p2: "error_rate > 1% for 10m"
|
||||||
|
p3: "error_rate > 0.1% for 1h"
|
||||||
|
owner_branch: feature-metrics-alerting-contract
|
||||||
|
log_field_mapping: [method, status, uri_template]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-metrics-alerting-contract — P1/P2/P3 정량 기준 (HTTP latency p99)
|
||||||
|
# "P1: p99 > 5s 5분 / P2: p99 > 1s 10분 / P3: p99 > 500ms 30분"
|
||||||
|
- name: http.server.requests.latency
|
||||||
|
type: timer
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: method
|
||||||
|
cardinality_limit: 8
|
||||||
|
allowed_values: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, OTHER]
|
||||||
|
- name: uri_template
|
||||||
|
cardinality_limit: 200
|
||||||
|
validation: must_be_template_not_raw_uri
|
||||||
|
percentiles: [0.5, 0.9, 0.95, 0.99]
|
||||||
|
histogram_buckets: slo_driven
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "p99 > 5s for 5m"
|
||||||
|
p2: "p99 > 1s for 10m"
|
||||||
|
p3: "p99 > 500ms for 30m"
|
||||||
|
owner_branch: feature-metrics-alerting-contract
|
||||||
|
log_field_mapping: [method, uri_template, duration_ms]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# === HTTP client (outbound dependency) ===
|
||||||
|
# source: feature-metrics-alerting-contract — Metric/Alert Defaults
|
||||||
|
# "dependency metric | dependency.client.requests with dependency.name/type/outcome | endpoint with secret tag"
|
||||||
|
# source: feature-outbound-http-client-baseline — "circuit breaker metric은 dependency.name, dependency.type, outcome까지만 tag로 허용"
|
||||||
|
- name: dependency.client.requests
|
||||||
|
type: timer
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: dependency_name
|
||||||
|
cardinality_limit: 50
|
||||||
|
- name: dependency_type
|
||||||
|
cardinality_limit: 10
|
||||||
|
allowed_values: [http, grpc, db, cache, queue, broker, other]
|
||||||
|
- name: outcome
|
||||||
|
cardinality_limit: 5
|
||||||
|
allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED]
|
||||||
|
percentiles: [0.5, 0.9, 0.95, 0.99]
|
||||||
|
histogram_buckets: slo_driven
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "required dep unavailable for 2m"
|
||||||
|
p2: "optional dep degraded for 5m"
|
||||||
|
p3: "spike alert (10x baseline)"
|
||||||
|
owner_branch: feature-metrics-alerting-contract
|
||||||
|
log_field_mapping: [dependency_name, dependency_type, outcome, duration_ms]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-outbound-http-client-baseline — decisions
|
||||||
|
# "retry/circuit breaker 기본 라이브러리는 Resilience4j"
|
||||||
|
# source: feature-metrics-alerting-contract — "retry/CB minimum: resilience4j.retry.calls{outcome}"
|
||||||
|
- name: resilience4j.retry.calls
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: name
|
||||||
|
cardinality_limit: 50
|
||||||
|
- name: outcome
|
||||||
|
cardinality_limit: 5
|
||||||
|
allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "retry exhaustion rate > 1% for 10m"
|
||||||
|
owner_branch: feature-outbound-http-client-baseline
|
||||||
|
log_field_mapping: [dependency_name, outcome, retry_attempt]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-metrics-alerting-contract — "resilience4j.circuitbreaker.state"
|
||||||
|
- name: resilience4j.circuitbreaker.state
|
||||||
|
type: gauge
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: name
|
||||||
|
cardinality_limit: 50
|
||||||
|
- name: state
|
||||||
|
cardinality_limit: 6
|
||||||
|
allowed_values: [CLOSED, OPEN, HALF_OPEN, DISABLED, FORCED_OPEN, METRICS_ONLY]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "state == OPEN for required dependency for 2m"
|
||||||
|
p2: "state == OPEN for optional dependency for 5m"
|
||||||
|
owner_branch: feature-outbound-http-client-baseline
|
||||||
|
log_field_mapping: [dependency_name]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-metrics-alerting-contract — "resilience4j.circuitbreaker.calls{outcome}"
|
||||||
|
- name: resilience4j.circuitbreaker.calls
|
||||||
|
type: timer
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: name
|
||||||
|
cardinality_limit: 50
|
||||||
|
- name: outcome
|
||||||
|
cardinality_limit: 5
|
||||||
|
allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED]
|
||||||
|
percentiles: [0.5, 0.9, 0.95, 0.99]
|
||||||
|
histogram_buckets: slo_driven
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "CIRCUIT_OPEN rate > 1% for 10m"
|
||||||
|
owner_branch: feature-outbound-http-client-baseline
|
||||||
|
log_field_mapping: [dependency_name, outcome, duration_ms]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# === DB connection pool ===
|
||||||
|
# source: feature-persistence-failure-baseline — Hikari Alert Threshold
|
||||||
|
# "pool wait p99 > 100ms 5분 지속 → P2 / pool exhaustion (active = max) > 1분 → P1"
|
||||||
|
# source: feature-metrics-alerting-contract — "hikaricp.connections.acquire{outcome='timeout'} p99 > 100ms"
|
||||||
|
- name: hikaricp.connections.acquire
|
||||||
|
type: timer
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: pool
|
||||||
|
cardinality_limit: 5
|
||||||
|
- name: outcome
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [SUCCESS, TIMEOUT, FAILURE]
|
||||||
|
percentiles: [0.5, 0.9, 0.95, 0.99]
|
||||||
|
histogram_buckets: slo_driven
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "pool exhaustion (active == max) for 1m"
|
||||||
|
p2: "acquire p99 > 100ms for 5m"
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
log_field_mapping: [pool, outcome]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline — In scope "Hikari metric 노출 기준" + Hikari Alert Threshold
|
||||||
|
- name: hikaricp.connections.usage
|
||||||
|
type: timer
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: pool
|
||||||
|
cardinality_limit: 5
|
||||||
|
percentiles: [0.5, 0.9, 0.95, 0.99]
|
||||||
|
histogram_buckets: slo_driven
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "usage p99 elevated > 10m"
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
log_field_mapping: [pool]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-persistence-failure-baseline — In scope "Hikari metric 노출 기준"
|
||||||
|
- name: hikaricp.connections.active
|
||||||
|
type: gauge
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: pool
|
||||||
|
cardinality_limit: 5
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "active == max for 1m"
|
||||||
|
owner_branch: feature-persistence-failure-baseline
|
||||||
|
log_field_mapping: [pool]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-metrics-alerting-contract — Histogram Buckets/Percentile "DB query: same"
|
||||||
|
- name: db.query.duration
|
||||||
|
type: timer
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: operation
|
||||||
|
cardinality_limit: 20
|
||||||
|
allowed_values: [select, insert, update, delete, batch, ddl, other]
|
||||||
|
- name: outcome
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [SUCCESS, FAILURE, TIMEOUT]
|
||||||
|
percentiles: [0.5, 0.9, 0.95, 0.99]
|
||||||
|
histogram_buckets: slo_driven
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "p99 > 1s for 10m"
|
||||||
|
owner_branch: feature-metrics-alerting-contract
|
||||||
|
log_field_mapping: [operation, outcome, duration_ms]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# === Background job / async executor ===
|
||||||
|
# source: feature-background-job-async-contract — Decisionized Work Items "saturation policy"
|
||||||
|
# "AbortPolicy default (core=10, max=50, queue=200)"
|
||||||
|
- name: executor.saturation
|
||||||
|
type: gauge
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: executor_name
|
||||||
|
cardinality_limit: 10
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "queue size > 80% capacity for 5m"
|
||||||
|
p1: "rejection rate > 0 for 1m"
|
||||||
|
owner_branch: feature-background-job-async-contract
|
||||||
|
log_field_mapping: [executor_name]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-background-job-async-contract — Decisionized Work Items "saturation | bounded executor + rejection log"
|
||||||
|
- name: executor.rejected.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: executor_name
|
||||||
|
cardinality_limit: 10
|
||||||
|
- name: policy
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [AbortPolicy, CallerRunsPolicy, DiscardPolicy]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "rejection_count > 0 for 1m"
|
||||||
|
owner_branch: feature-background-job-async-contract
|
||||||
|
log_field_mapping: [executor_name, policy]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-background-job-async-contract — Decisionized Work Items "retry/DLQ | exp backoff jitter, max 3, DLQ exhausted"
|
||||||
|
- name: job.retry.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: job_name
|
||||||
|
cardinality_limit: 50
|
||||||
|
- name: outcome
|
||||||
|
cardinality_limit: 4
|
||||||
|
allowed_values: [SUCCESS, RETRY, EXHAUSTED, DLQ]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "EXHAUSTED rate > 1% for 10m"
|
||||||
|
owner_branch: feature-background-job-async-contract
|
||||||
|
log_field_mapping: [job_name, outcome, retry_attempt]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-background-job-async-contract — "DLQ after exhausted attempts"
|
||||||
|
- name: job.dlq.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: job_name
|
||||||
|
cardinality_limit: 50
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "DLQ rate sustained > 0 for 5m"
|
||||||
|
owner_branch: feature-background-job-async-contract
|
||||||
|
log_field_mapping: [job_name]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# === Distributed lock ===
|
||||||
|
# source: feature-distributed-lock-contract D7 — "metric lock.acquisition (tag: outcome =
|
||||||
|
# acquired/timeout/error) — 신규 제안" / D5 — try-lock + 유한 waitTime + lease(TTL). 분산
|
||||||
|
# 상호배제(distributedLockProvider) 획득 시도 결과를 센다. key 는 tag 로 넣지 않는다
|
||||||
|
# (무한 cardinality — 위 전역 금지 규칙). timeout outcome 은 LOCK_ACQUISITION_TIMEOUT 발생과 1:1.
|
||||||
|
- name: lock.acquisition
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: outcome
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [acquired, timeout, error]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "timeout rate > 5% for 10m"
|
||||||
|
owner_branch: feature-distributed-lock-contract
|
||||||
|
log_field_mapping: [outcome]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-distributed-lock-contract §Edge / D5 (SI-LOCK-C5) — "lease 만료 후 unlock →
|
||||||
|
# ConcurrentModificationException — 삼킴 금지, 로그+metric 후 정상 흐름 복귀". Counts releases that
|
||||||
|
# found the lease already expired (the JdbcLock row was reclaimed by another instance before
|
||||||
|
# the holder called close()). A sustained nonzero rate means lease TTL is shorter than real
|
||||||
|
# critical-section duration — raise APP/lease TTL or shorten the protected work. Not an
|
||||||
|
# acquisition outcome, hence a separate counter from lock.acquisition.
|
||||||
|
- name: lock.lease.expired
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags: []
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "lease-expired rate sustained > 0 for 10m"
|
||||||
|
owner_branch: feature-distributed-lock-contract
|
||||||
|
log_field_mapping: []
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# === Outbox publisher ===
|
||||||
|
# source: feature-domain-event-outbox-contract — Outbox Defaults
|
||||||
|
# "DB outbox table with eventId, aggregateId, eventType, payload, occurredAt, status, attemptCount, nextAttemptAt"
|
||||||
|
- name: outbox.publisher.published.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: event_type
|
||||||
|
cardinality_limit: 50
|
||||||
|
- name: outcome
|
||||||
|
cardinality_limit: 4
|
||||||
|
allowed_values: [PUBLISHED, FAILED, DEAD, IN_FLIGHT]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "FAILED rate > 1% for 10m"
|
||||||
|
owner_branch: feature-domain-event-outbox-contract
|
||||||
|
log_field_mapping: [event_type, outcome, event_id]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-domain-event-outbox-contract — row status enum PENDING/IN_FLIGHT/PUBLISHED/FAILED/DEAD
|
||||||
|
- name: outbox.publisher.lag
|
||||||
|
type: gauge
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: event_type
|
||||||
|
cardinality_limit: 50
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "lag > 60s for 10m"
|
||||||
|
p1: "lag > 300s for 5m"
|
||||||
|
owner_branch: feature-domain-event-outbox-contract
|
||||||
|
log_field_mapping: [event_type]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-domain-event-outbox-contract — row status enum + Outbox Defaults attemptCount
|
||||||
|
- name: outbox.pending.size
|
||||||
|
type: gauge
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: status
|
||||||
|
cardinality_limit: 5
|
||||||
|
allowed_values: [PENDING, IN_FLIGHT, PUBLISHED, FAILED, DEAD]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "PENDING size growing for 10m"
|
||||||
|
owner_branch: feature-domain-event-outbox-contract
|
||||||
|
log_field_mapping: [status]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# === Cache ===
|
||||||
|
# source: feature-cache-consistency-contract — Decisionized Work Items "cache pattern | cache-aside default"
|
||||||
|
- name: cache.gets.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: cache_name
|
||||||
|
cardinality_limit: 50
|
||||||
|
- name: result
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [hit, miss, error]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p3: "hit_ratio < baseline 0.5x for 1h"
|
||||||
|
owner_branch: feature-cache-consistency-contract
|
||||||
|
log_field_mapping: [cache_name, result]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-cache-consistency-contract — "invalidation = after-commit only", "invalidation 실패가 조용히 무시되면 실패"
|
||||||
|
- name: cache.invalidations.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: cache_name
|
||||||
|
cardinality_limit: 50
|
||||||
|
- name: outcome
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [SUCCESS, FAILURE, SKIPPED]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "FAILURE rate > 0 for 5m"
|
||||||
|
owner_branch: feature-cache-consistency-contract
|
||||||
|
log_field_mapping: [cache_name, outcome]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: redis-production-capability — optional bounded cache-only L1
|
||||||
|
- name: cache.local.requests.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: cache_name
|
||||||
|
cardinality_limit: 50
|
||||||
|
- name: result
|
||||||
|
cardinality_limit: 4
|
||||||
|
allowed_values: [hit, miss, error, bypass]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p3: "bypass or error rate above baseline for 15m"
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
log_field_mapping: [cache_name, result]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: redis-production-capability — local stale-age bound
|
||||||
|
- name: cache.local.entry.age.seconds
|
||||||
|
type: timer
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: cache_name
|
||||||
|
cardinality_limit: 50
|
||||||
|
percentiles: [0.5, 0.9, 0.95, 0.99]
|
||||||
|
histogram_buckets: slo_driven
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p3: "p99 approaches configured local TTL for 30m"
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
log_field_mapping: [cache_name]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: redis-production-capability — bounded invalidation and generation reconciliation
|
||||||
|
- name: cache.local.maintenance.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: cache_name
|
||||||
|
cardinality_limit: 50
|
||||||
|
- name: event
|
||||||
|
cardinality_limit: 14
|
||||||
|
allowed_values:
|
||||||
|
- evict_cardinality
|
||||||
|
- evict_weight
|
||||||
|
- evict_ttl
|
||||||
|
- evict_invalidation
|
||||||
|
- flush_invalidation
|
||||||
|
- reconcile_generation_changed
|
||||||
|
- reconcile_unchanged
|
||||||
|
- reconcile_error
|
||||||
|
- subscriber_disconnected
|
||||||
|
- subscriber_overflow
|
||||||
|
- subscriber_malformed
|
||||||
|
- subscriber_publish_success
|
||||||
|
- subscriber_publish_error
|
||||||
|
- other
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "reconcile_error, subscriber_overflow, or sustained disconnects for 5m"
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
log_field_mapping: [cache_name, event]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: redis-production-capability Task 16 — closed semantic operation outcomes
|
||||||
|
- name: redis.capability.operations.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: capability
|
||||||
|
cardinality_limit: 6
|
||||||
|
allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session, runtime]
|
||||||
|
- name: role
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [cache, coordination, session]
|
||||||
|
- name: operation
|
||||||
|
cardinality_limit: 24
|
||||||
|
allowed_values:
|
||||||
|
- lookup
|
||||||
|
- record
|
||||||
|
- invalidate
|
||||||
|
- refresh_claim
|
||||||
|
- refresh_release
|
||||||
|
- rate_evaluate
|
||||||
|
- idempotency_claim
|
||||||
|
- idempotency_start
|
||||||
|
- idempotency_renew
|
||||||
|
- idempotency_complete
|
||||||
|
- idempotency_fail
|
||||||
|
- idempotency_release
|
||||||
|
- idempotency_inspect
|
||||||
|
- lease_acquire
|
||||||
|
- lease_inspect
|
||||||
|
- lease_renew
|
||||||
|
- lease_release
|
||||||
|
- session_create
|
||||||
|
- session_inspect
|
||||||
|
- session_save
|
||||||
|
- session_touch
|
||||||
|
- session_revoke
|
||||||
|
- session_rotate
|
||||||
|
- route_command
|
||||||
|
- name: redis_outcome
|
||||||
|
cardinality_limit: 15
|
||||||
|
allowed_values:
|
||||||
|
- success
|
||||||
|
- hit
|
||||||
|
- miss
|
||||||
|
- denied
|
||||||
|
- contended
|
||||||
|
- conflict
|
||||||
|
- incompatible
|
||||||
|
- unavailable
|
||||||
|
- overloaded
|
||||||
|
- closed
|
||||||
|
- indeterminate
|
||||||
|
- stale
|
||||||
|
- skipped
|
||||||
|
- tombstoned
|
||||||
|
- absolute_expired
|
||||||
|
- name: certainty
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [definite, not_applied, indeterminate]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "required coordination/session unavailable or indeterminate mutation sustained for 2m"
|
||||||
|
p2: "optional cache unavailable or overloaded above baseline for 5m"
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
log_field_mapping: [capability, role, operation, redis_outcome, certainty]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: redis-production-capability Task 16 — monotonic semantic operation duration
|
||||||
|
- name: redis.capability.duration.seconds
|
||||||
|
type: timer
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: capability
|
||||||
|
cardinality_limit: 6
|
||||||
|
allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session, runtime]
|
||||||
|
- name: role
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [cache, coordination, session]
|
||||||
|
- name: operation
|
||||||
|
cardinality_limit: 24
|
||||||
|
allowed_values:
|
||||||
|
- lookup
|
||||||
|
- record
|
||||||
|
- invalidate
|
||||||
|
- refresh_claim
|
||||||
|
- refresh_release
|
||||||
|
- rate_evaluate
|
||||||
|
- idempotency_claim
|
||||||
|
- idempotency_start
|
||||||
|
- idempotency_renew
|
||||||
|
- idempotency_complete
|
||||||
|
- idempotency_fail
|
||||||
|
- idempotency_release
|
||||||
|
- idempotency_inspect
|
||||||
|
- lease_acquire
|
||||||
|
- lease_inspect
|
||||||
|
- lease_renew
|
||||||
|
- lease_release
|
||||||
|
- session_create
|
||||||
|
- session_inspect
|
||||||
|
- session_save
|
||||||
|
- session_touch
|
||||||
|
- session_revoke
|
||||||
|
- session_rotate
|
||||||
|
- route_command
|
||||||
|
- name: redis_outcome
|
||||||
|
cardinality_limit: 15
|
||||||
|
allowed_values:
|
||||||
|
- success
|
||||||
|
- hit
|
||||||
|
- miss
|
||||||
|
- denied
|
||||||
|
- contended
|
||||||
|
- conflict
|
||||||
|
- incompatible
|
||||||
|
- unavailable
|
||||||
|
- overloaded
|
||||||
|
- closed
|
||||||
|
- indeterminate
|
||||||
|
- stale
|
||||||
|
- skipped
|
||||||
|
- tombstoned
|
||||||
|
- absolute_expired
|
||||||
|
percentiles: [0.5, 0.9, 0.95, 0.99]
|
||||||
|
histogram_buckets: slo_driven
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "p99 approaches the configured command or caller deadline for 10m"
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
log_field_mapping: [capability, role, operation, redis_outcome]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: redis-production-capability Task 16 — admission rejected before command ownership
|
||||||
|
- name: redis.capability.admission.rejected.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: role
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [cache, coordination, session]
|
||||||
|
- name: admission
|
||||||
|
cardinality_limit: 2
|
||||||
|
allowed_values: [rejected_saturated, rejected_closed]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "required role rejection sustained above zero for 2m"
|
||||||
|
p2: "optional cache saturation sustained for 5m"
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
log_field_mapping: [role, admission]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: redis-production-capability Task 16 — bounded admitted command count observation
|
||||||
|
- name: redis.capability.inflight.total
|
||||||
|
type: gauge
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: role
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [cache, coordination, session]
|
||||||
|
- name: state
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [idle, active, saturated]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "saturated series remains nonzero for 5m"
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
log_field_mapping: [role, state]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: redis-production-capability Task 16 — observations of exact sanitized RoleHealth
|
||||||
|
- name: redis.capability.readiness.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: capability
|
||||||
|
cardinality_limit: 5
|
||||||
|
allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session]
|
||||||
|
- name: role
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [cache, coordination, session]
|
||||||
|
- name: state
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [available, unavailable, overloaded]
|
||||||
|
- name: reason
|
||||||
|
cardinality_limit: 11
|
||||||
|
allowed_values:
|
||||||
|
- command_unavailable
|
||||||
|
- route_closed
|
||||||
|
- semantic_probe_succeeded
|
||||||
|
- semantic_read_write_failed
|
||||||
|
- semantic_program_acl_denied
|
||||||
|
- semantic_program_failed
|
||||||
|
- server_version_unsupported
|
||||||
|
- semantic_probe_in_progress
|
||||||
|
- semantic_observation_stale
|
||||||
|
- command_saturated
|
||||||
|
- recent_command_failure
|
||||||
|
- name: requirement
|
||||||
|
cardinality_limit: 2
|
||||||
|
allowed_values: [optional, required]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "required coordination/session unavailable for 2m"
|
||||||
|
p2: "optional cache unavailable or overloaded for 5m"
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
log_field_mapping: [capability, role, state, reason, requirement]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: redis-production-capability Task 16 — bounded router shutdown drain result
|
||||||
|
- name: redis.capability.lifecycle.drain.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: role
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [cache, coordination, session]
|
||||||
|
- name: drain_outcome
|
||||||
|
cardinality_limit: 3
|
||||||
|
allowed_values: [drained, forced_after_timeout, interrupted]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "required role forced_after_timeout or interrupted during shutdown"
|
||||||
|
p2: "optional cache forced close during shutdown"
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
log_field_mapping: [role, drain_outcome]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# === Log appender ===
|
||||||
|
# source: feature-log-management-contract — Sampling Policy (final)
|
||||||
|
# "async appender overflow default: drop oldest INFO/DEBUG with counter metric (log.appender.dropped.total)"
|
||||||
|
- name: log.appender.dropped.total
|
||||||
|
type: counter
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: appender
|
||||||
|
cardinality_limit: 5
|
||||||
|
- name: level
|
||||||
|
cardinality_limit: 2
|
||||||
|
allowed_values: [INFO, DEBUG]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "dropped > 0 sustained for 10m"
|
||||||
|
owner_branch: feature-log-management-contract
|
||||||
|
log_field_mapping: [appender, level]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# === Distributed tracing ===
|
||||||
|
# source: feature-distributed-tracing-contract — decisions
|
||||||
|
# "trace sampling rate default = prod 1%, staging 10%, dev/local 100%"
|
||||||
|
- name: tracing.sampling.rate
|
||||||
|
type: gauge
|
||||||
|
unit: total
|
||||||
|
tags:
|
||||||
|
- name: profile
|
||||||
|
cardinality_limit: 4
|
||||||
|
allowed_values: [prod, staging, dev, local]
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p3: "effective rate deviates from configured for 1h"
|
||||||
|
owner_branch: feature-distributed-tracing-contract
|
||||||
|
log_field_mapping: [profile]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# === JVM baseline ===
|
||||||
|
# source: feature-metrics-alerting-contract — In scope "JVM/process metric"
|
||||||
|
- name: jvm.memory.used
|
||||||
|
type: gauge
|
||||||
|
unit: bytes
|
||||||
|
tags:
|
||||||
|
- name: area
|
||||||
|
cardinality_limit: 2
|
||||||
|
allowed_values: [heap, nonheap]
|
||||||
|
- name: id
|
||||||
|
cardinality_limit: 10
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "heap used / max > 0.85 for 10m"
|
||||||
|
owner_branch: feature-metrics-alerting-contract
|
||||||
|
log_field_mapping: [area, id]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-metrics-alerting-contract — In scope "JVM/process metric"
|
||||||
|
- name: jvm.gc.pause
|
||||||
|
type: timer
|
||||||
|
unit: seconds
|
||||||
|
tags:
|
||||||
|
- name: action
|
||||||
|
cardinality_limit: 10
|
||||||
|
- name: cause
|
||||||
|
cardinality_limit: 10
|
||||||
|
percentiles: [0.5, 0.9, 0.95, 0.99]
|
||||||
|
histogram_buckets: slo_driven
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p2: "p99 pause > 500ms for 10m"
|
||||||
|
owner_branch: feature-metrics-alerting-contract
|
||||||
|
log_field_mapping: [action, cause]
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-metrics-alerting-contract — In scope "JVM/process metric"
|
||||||
|
- name: jvm.threads.live
|
||||||
|
type: gauge
|
||||||
|
unit: total
|
||||||
|
tags: []
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p3: "thread count > 2x baseline for 30m"
|
||||||
|
owner_branch: feature-metrics-alerting-contract
|
||||||
|
log_field_mapping: []
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
|
|
||||||
|
# source: feature-metrics-alerting-contract — In scope "JVM/process metric" (process uptime)
|
||||||
|
- name: process.uptime
|
||||||
|
type: gauge
|
||||||
|
unit: seconds
|
||||||
|
tags: []
|
||||||
|
percentiles: null
|
||||||
|
histogram_buckets: null
|
||||||
|
alert_severity_thresholds:
|
||||||
|
p1: "uptime reset unexpectedly < 60s (crash loop signal)"
|
||||||
|
owner_branch: feature-metrics-alerting-contract
|
||||||
|
log_field_mapping: []
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: contract-verification:metrics-cardinality
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Repository owner test: dev.caskeleton.bootstrap.contract.ContractRegistrySchemaGovernanceTest
|
||||||
|
# Owner Gradle path: :app-bootstrap:test
|
||||||
|
# Semantic owner test: dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageReadinessRegistryTest
|
||||||
|
# Semantic owner Gradle path: :adapter:outbound:objectstorage:test
|
||||||
|
schema_version: 1
|
||||||
|
claims:
|
||||||
|
- card_id: object-storage-managed-upload-single
|
||||||
|
provider_type: filesystem-local-dev
|
||||||
|
provider_version: jdk-21
|
||||||
|
destination_profile: local-managed-integrity
|
||||||
|
claimed_level: R1
|
||||||
|
evidence_revision: batch-b-local-r1
|
||||||
|
evidence_expires_on: ""
|
||||||
|
required_tasks:
|
||||||
|
- ":adapter:outbound:objectstorage:check"
|
||||||
|
limitations:
|
||||||
|
- single-process control CAS only; no multi-node linearizability
|
||||||
|
- fsync and atomic move tests do not prove power-loss durability
|
||||||
|
- local development provider is forbidden in production profiles
|
||||||
|
|
||||||
|
- card_id: object-storage-managed-upload-multipart
|
||||||
|
provider_type: filesystem-local-dev
|
||||||
|
provider_version: jdk-21
|
||||||
|
destination_profile: local-unimplemented
|
||||||
|
claimed_level: R0
|
||||||
|
evidence_revision: batch-b-contract-r0
|
||||||
|
evidence_expires_on: ""
|
||||||
|
required_tasks:
|
||||||
|
- ":application-core:check"
|
||||||
|
limitations:
|
||||||
|
- multipart publication protocol is not implemented
|
||||||
|
|
||||||
|
- card_id: object-storage-managed-download
|
||||||
|
provider_type: filesystem-local-dev
|
||||||
|
provider_version: jdk-21
|
||||||
|
destination_profile: local-managed-integrity
|
||||||
|
claimed_level: R1
|
||||||
|
evidence_revision: batch-b-local-r1
|
||||||
|
evidence_expires_on: ""
|
||||||
|
required_tasks:
|
||||||
|
- ":adapter:outbound:objectstorage:check"
|
||||||
|
limitations:
|
||||||
|
- local functional full and range reads are not production-provider qualification
|
||||||
|
- no multi-node or power-loss durability claim
|
||||||
|
|
||||||
|
- card_id: object-storage-direct-upload-single
|
||||||
|
provider_type: filesystem-local-dev
|
||||||
|
provider_version: jdk-21
|
||||||
|
destination_profile: local-unimplemented
|
||||||
|
claimed_level: R0
|
||||||
|
evidence_revision: batch-b-contract-r0
|
||||||
|
evidence_expires_on: ""
|
||||||
|
required_tasks:
|
||||||
|
- ":application-core:check"
|
||||||
|
limitations:
|
||||||
|
- direct grant provider and public inbound endpoint are not implemented
|
||||||
|
|
||||||
|
- card_id: object-storage-direct-upload-multipart
|
||||||
|
provider_type: filesystem-local-dev
|
||||||
|
provider_version: jdk-21
|
||||||
|
destination_profile: local-unimplemented
|
||||||
|
claimed_level: R0
|
||||||
|
evidence_revision: batch-b-contract-r0
|
||||||
|
evidence_expires_on: ""
|
||||||
|
required_tasks:
|
||||||
|
- ":application-core:check"
|
||||||
|
limitations:
|
||||||
|
- direct multipart session and public inbound endpoint are not implemented
|
||||||
|
|
||||||
|
- card_id: object-storage-direct-download
|
||||||
|
provider_type: filesystem-local-dev
|
||||||
|
provider_version: jdk-21
|
||||||
|
destination_profile: local-unimplemented
|
||||||
|
claimed_level: R0
|
||||||
|
evidence_revision: batch-b-contract-r0
|
||||||
|
evidence_expires_on: ""
|
||||||
|
required_tasks:
|
||||||
|
- ":application-core:check"
|
||||||
|
limitations:
|
||||||
|
- direct download grant and public inbound endpoint are not implemented
|
||||||
|
|
||||||
|
- card_id: object-storage-quarantine-publication
|
||||||
|
provider_type: filesystem-local-dev
|
||||||
|
provider_version: jdk-21
|
||||||
|
destination_profile: local-unimplemented
|
||||||
|
claimed_level: R0
|
||||||
|
evidence_revision: batch-b-contract-r0
|
||||||
|
evidence_expires_on: ""
|
||||||
|
required_tasks:
|
||||||
|
- ":application-core:check"
|
||||||
|
limitations:
|
||||||
|
- scanner handoff and verdict fencing are not implemented
|
||||||
|
|
||||||
|
- card_id: object-storage-retention
|
||||||
|
provider_type: filesystem-local-dev
|
||||||
|
provider_version: jdk-21
|
||||||
|
destination_profile: local-unimplemented
|
||||||
|
claimed_level: R0
|
||||||
|
evidence_revision: batch-b-contract-r0
|
||||||
|
evidence_expires_on: ""
|
||||||
|
required_tasks:
|
||||||
|
- ":application-core:check"
|
||||||
|
limitations:
|
||||||
|
- retention and legal-hold provider enforcement are not implemented
|
||||||
|
- privileged purge composition remains intentionally empty
|
||||||
|
|
||||||
|
- card_id: object-storage-reconciliation
|
||||||
|
provider_type: filesystem-local-dev
|
||||||
|
provider_version: jdk-21
|
||||||
|
destination_profile: local-unimplemented
|
||||||
|
claimed_level: R0
|
||||||
|
evidence_revision: batch-b-contract-r0
|
||||||
|
evidence_expires_on: ""
|
||||||
|
required_tasks:
|
||||||
|
- ":application-core:check"
|
||||||
|
limitations:
|
||||||
|
- local create resolution is single-process functional evidence only
|
||||||
|
- production response-loss and multi-node reconciliation are not implemented
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
# Registry: Secrets Classification
|
||||||
|
# SSOT: wiki/projects/ca-tmpl/registries/secrets-classification.yaml
|
||||||
|
# Schema owner: feature-contract-registry-governance
|
||||||
|
# Owner branch: feature-secrets-config-source-contract
|
||||||
|
# Last updated: 2026-05-22
|
||||||
|
#
|
||||||
|
# Conventions:
|
||||||
|
# - 3-tier classification (feature-secrets-config-source-contract 2026-05-22):
|
||||||
|
# public-config | sensitive-config | secret
|
||||||
|
# - `secret` rows: prod_default 항상 null. dev fake 식별자는 `__LOCAL_DEV_` prefix
|
||||||
|
# (feature-secrets-config-source-contract 2026-05-22: "dev/local sentinel value prefix = __LOCAL_DEV_").
|
||||||
|
# - prod profile에서 `__LOCAL_DEV_` prefix 발견 시 startup fail
|
||||||
|
# (feature-secrets-config-source-contract 2026-05-22).
|
||||||
|
# - Masking 기본 = `full_except_last_4` (feature-secrets-config-source-contract 2026-05-22:
|
||||||
|
# "full mask except last 4 chars for non-secret tokens"). 진짜 secret(password/private key)은 `full`.
|
||||||
|
# - Naming suffix는 보조 신호 (feature-secrets-config-source-contract: "_TOKEN, _KEY, _PASSWORD").
|
||||||
|
# - public-config 항목은 env-keys.yaml에서 직접 정의되며 본 파일에는 reference row만 둠.
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
|
||||||
|
# === Tier 3: secret (true secret — password/private-key/HMAC-salt) ===
|
||||||
|
|
||||||
|
- name: APP_DATASOURCE_PASSWORD
|
||||||
|
# source: feature-secrets-config-source-contract 2026-05-22
|
||||||
|
# "DB credential은 dual-bind 60s" + "__LOCAL_DEV_FAKE_DB_PASSWORD" 예시
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: dual-bind-60s
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: feature-secrets-config-source-contract
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: secrets-contract:db-password-no-leak-in-actuator
|
||||||
|
|
||||||
|
- name: APP_SECURITY_JWT_SIGNING_KEY
|
||||||
|
# source: feature-secrets-config-source-contract 2026-05-22
|
||||||
|
# "JWT signing key는 24h overlap window 유지 (security branch와 cross-link)"
|
||||||
|
# + feature-security-operational-baseline "rotation overlap window = 새 kid 도입 → 24h 동안 old kid 병행"
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: overlap-24h
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: feature-secrets-config-source-contract
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: secrets-contract:jwt-signing-key-rotation-overlap
|
||||||
|
|
||||||
|
- name: APP_SECURITY_OAUTH_CLIENT_SECRET
|
||||||
|
# source: feature-secrets-config-source-contract 2026-05-22
|
||||||
|
# "secret classification은 ... naming pattern은 보조(suffix _TOKEN, _KEY, _PASSWORD)"
|
||||||
|
# + feature-security-operational-baseline "JWT Resource Server를 baseline security model" (OAuth 자격 증명 분류)
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: feature-secrets-config-source-contract
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: secrets-contract:oauth-client-secret-no-leak
|
||||||
|
|
||||||
|
- name: APP_EXTERNAL_API_KEY
|
||||||
|
# source: feature-secrets-config-source-contract 2026-05-22
|
||||||
|
# "external API key는 application restart 시 reload"
|
||||||
|
# (per-dependency suffix는 adapter 등록 시 추가; 본 row는 baseline 분류 정의)
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: feature-secrets-config-source-contract
|
||||||
|
masking_rule: full_except_last_4
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: secrets-contract:external-api-key-no-leak
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_PASSWORD
|
||||||
|
# source: feature-secrets-config-source-contract 2026-05-22
|
||||||
|
# 3-tier classification "secret" + feature-integration-adapter-templates "Redis | disabled optional module"
|
||||||
|
# (Redis enabled + auth 사용 시 secret으로 분류)
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: feature-secrets-config-source-contract
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: secrets-contract:redis-password-no-leak
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_TRUST_PEM
|
||||||
|
# Public CA bundle content, but integrity-sensitive and supplied by the mounted environment.
|
||||||
|
classification: sensitive-config
|
||||||
|
source: mounted-env
|
||||||
|
rotation_policy: restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: secrets-contract:redis-trust-reference-no-leak
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_KEY_HMAC_SECRET
|
||||||
|
# Stable cache-key HMAC material. It is distinct from the Redis authentication credential.
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: dual-read-restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: codex-phase-a-ci-recovery
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: secrets-contract:redis-key-hmac-no-leak
|
||||||
|
|
||||||
|
- name: APP_RATE_LIMIT_REDIS_PASSWORD
|
||||||
|
# Dedicated coordination-role Redis credential. It is never inherited from cache Redis.
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: redis-distributed-rate-limit
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: secrets-contract:rate-limit-redis-password-no-leak
|
||||||
|
|
||||||
|
- name: APP_RATE_LIMIT_REDIS_TRUST_PEM
|
||||||
|
# Coordination-role CA bundle content; integrity-sensitive but not credential material.
|
||||||
|
classification: sensitive-config
|
||||||
|
source: mounted-env
|
||||||
|
rotation_policy: restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: secrets-contract:rate-limit-redis-trust-reference-no-leak
|
||||||
|
|
||||||
|
- name: APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET
|
||||||
|
# Stable private-key derivation material for rate-limit subjects and policy revisions.
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: dual-read-restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: redis-distributed-rate-limit
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: secrets-contract:rate-limit-redis-key-hmac-no-leak
|
||||||
|
|
||||||
|
- name: APP_SESSION_REDIS_PASSWORD
|
||||||
|
# Dedicated session-role ACL credential; never shared implicitly with cache or coordination.
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: secrets-contract:session-redis-password-no-leak
|
||||||
|
|
||||||
|
- name: APP_SESSION_REDIS_TRUST_PEM
|
||||||
|
# Session-role CA bundle content; integrity-sensitive but not credential material.
|
||||||
|
classification: sensitive-config
|
||||||
|
source: mounted-env
|
||||||
|
rotation_policy: restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: secrets-contract:session-redis-trust-reference-no-leak
|
||||||
|
|
||||||
|
- name: APP_SESSION_REDIS_KEY_HMAC_SECRET
|
||||||
|
# Stable private derivation material for pseudonymous Redis session keys.
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: dual-read-restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: secrets-contract:session-redis-key-hmac-no-leak
|
||||||
|
|
||||||
|
- name: APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET
|
||||||
|
# Owner-safe request-replay keys must not expose tenant/scope/request identifiers.
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: cold-cutover-restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: redis-production-capability-completion
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: secrets-contract:idempotency-redis-key-hmac-no-leak
|
||||||
|
|
||||||
|
- name: APP_LEASE_REDIS_KEY_HMAC_SECRET
|
||||||
|
# Efficiency-lease resource and owner scopes use a dedicated derivation key.
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: cold-cutover-restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: redis-production-capability-completion
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: secrets-contract:lease-redis-key-hmac-no-leak
|
||||||
|
|
||||||
|
- name: APP_PRIVACY_PSEUDONYMIZATION_SALT
|
||||||
|
# source: feature-data-retention-privacy-contract 2026-05-22
|
||||||
|
# "pseudonymization key = HMAC-SHA-256 with rotating salt. salt rotation interval = 90일.
|
||||||
|
# rotation 시 old salt 90일 retain (lookup 가능)."
|
||||||
|
# + feature-tenant-context-policy "tenant identifier는 raw PII가 아니어야 하며 ... pseudonymized id"
|
||||||
|
classification: secret
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: salt-rotation-90d
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: feature-data-retention-privacy-contract
|
||||||
|
masking_rule: full
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: secrets-contract:pseudonymization-salt-rotation
|
||||||
|
|
||||||
|
# === Tier 2: sensitive-config (token-bearing URL or id with exposure restriction) ===
|
||||||
|
|
||||||
|
- name: APP_NOTIFICATION_SLACK_WEBHOOK_URL
|
||||||
|
# source: feature-integration-adapter-templates 2026-05-22
|
||||||
|
# "Slack | disabled optional module | notification failure policy"
|
||||||
|
# Slack webhook URL은 token을 path에 포함하므로 sensitive-config (URL 형태이지만 secret과 동급 취급)
|
||||||
|
classification: sensitive-config
|
||||||
|
source: secret-manager
|
||||||
|
rotation_policy: manual
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: feature-secrets-config-source-contract
|
||||||
|
masking_rule: full_except_last_4
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: secrets-contract:slack-webhook-no-leak
|
||||||
|
|
||||||
|
- name: APP_SECURITY_GOOGLE_OAUTH_CLIENT_ID
|
||||||
|
# source: feature-secrets-config-source-contract 2026-05-22
|
||||||
|
# "sensitive-config" tier (id이지만 노출 제한)
|
||||||
|
# + feature-integration-adapter-templates "Google Email | disabled optional module"
|
||||||
|
classification: sensitive-config
|
||||||
|
source: mounted-env
|
||||||
|
rotation_policy: manual
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: feature-secrets-config-source-contract
|
||||||
|
masking_rule: full_except_last_4
|
||||||
|
compatibility_impact: behavior-change
|
||||||
|
required_test: secrets-contract:google-oauth-client-id-masked
|
||||||
|
|
||||||
|
- name: APP_DATASOURCE_USERNAME
|
||||||
|
# source: feature-secrets-config-source-contract 2026-05-22 — "sensitive-config" tier
|
||||||
|
# (DB user는 password와 함께 노출되면 위험하므로 sensitive-config)
|
||||||
|
classification: sensitive-config
|
||||||
|
source: mounted-env
|
||||||
|
rotation_policy: dual-bind-60s
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: feature-secrets-config-source-contract
|
||||||
|
masking_rule: full_except_last_4
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: secrets-contract:datasource-username-masked-in-actuator
|
||||||
|
|
||||||
|
- name: APP_DATASOURCE_URL
|
||||||
|
# source: feature-secrets-config-source-contract 2026-05-22 — JDBC URL은 host/db 포함하므로 sensitive-config
|
||||||
|
# (env-keys.yaml에서는 public-config 처리; 본 파일에서는 노출 통제 관점에서 sensitive로 재분류 — masking 기준 명시 목적)
|
||||||
|
classification: sensitive-config
|
||||||
|
source: mounted-env
|
||||||
|
rotation_policy: restart-only
|
||||||
|
prod_default: null
|
||||||
|
dev_sentinel_prefix: __LOCAL_DEV_
|
||||||
|
owner_branch: feature-secrets-config-source-contract
|
||||||
|
masking_rule: full_except_last_4
|
||||||
|
compatibility_impact: breaking
|
||||||
|
required_test: secrets-contract:datasource-url-masked-in-actuator
|
||||||
|
|
||||||
|
# === Tier 1: public-config (reference only — full row in env-keys.yaml) ===
|
||||||
|
|
||||||
|
- name: APP_PROFILE
|
||||||
|
# source: feature-env-driven-runtime-configuration — public-config tier reference
|
||||||
|
classification: public-config
|
||||||
|
source: application-yml
|
||||||
|
owner_branch: feature-env-driven-runtime-configuration
|
||||||
|
masking_rule: none
|
||||||
|
reference: env-keys.yaml#APP_PROFILE
|
||||||
|
|
||||||
|
- name: APP_NAME
|
||||||
|
# source: feature-env-driven-runtime-configuration — public-config tier reference
|
||||||
|
classification: public-config
|
||||||
|
source: application-yml
|
||||||
|
owner_branch: feature-env-driven-runtime-configuration
|
||||||
|
masking_rule: none
|
||||||
|
reference: env-keys.yaml#APP_NAME
|
||||||
|
|
||||||
|
- name: SERVER_PORT
|
||||||
|
# source: feature-env-driven-runtime-configuration — Spring native, public-config tier reference
|
||||||
|
classification: public-config
|
||||||
|
source: application-yml
|
||||||
|
owner_branch: feature-env-driven-runtime-configuration
|
||||||
|
masking_rule: none
|
||||||
|
reference: env-keys.yaml#SERVER_PORT
|
||||||
|
|
||||||
|
- name: SPRING_PROFILES_ACTIVE
|
||||||
|
# source: feature-env-driven-runtime-configuration — Spring native, public-config tier reference
|
||||||
|
classification: public-config
|
||||||
|
source: application-yml
|
||||||
|
owner_branch: feature-env-driven-runtime-configuration
|
||||||
|
masking_rule: none
|
||||||
|
reference: env-keys.yaml#SPRING_PROFILES_ACTIVE
|
||||||
|
|
||||||
|
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||||
|
# source: feature-distributed-tracing-contract — public-config tier reference
|
||||||
|
classification: public-config
|
||||||
|
source: application-yml
|
||||||
|
owner_branch: feature-distributed-tracing-contract
|
||||||
|
masking_rule: none
|
||||||
|
reference: env-keys.yaml#OTEL_EXPORTER_OTLP_ENDPOINT
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — ADAPTER_DISABLED (런타임 어댑터 비활성화 호출)
|
||||||
|
category: INTERNAL
|
||||||
|
error_codes: [ADAPTER_DISABLED]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: ADAPTER_DISABLED (`runbook://adapter/adapter-disabled`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 500 with `error.code=ADAPTER_DISABLED`
|
||||||
|
- Code invoked an optional adapter (Kafka/Redis/Slack/Email) that is disabled in this deployment
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check adapter name in log (`adapter_name` field)
|
||||||
|
- Review deployment config — which optional adapters are enabled?
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Enable the adapter in deployment configuration (env flag)
|
||||||
|
- Or update application logic to skip disabled-adapter paths
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to deployment team if adapter should be enabled but isn't
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTH_AUDIENCE_MISMATCH (대상 불일치)
|
||||||
|
category: AUTH
|
||||||
|
error_codes: [AUTH_AUDIENCE_MISMATCH]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTH_AUDIENCE_MISMATCH (`runbook://auth/audience-mismatch`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 401 with `error.code=AUTH_AUDIENCE_MISMATCH`
|
||||||
|
- Token `aud` claim does not include this service's expected audience
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check token `aud` claim value
|
||||||
|
- Compare against configured `spring.security.oauth2.resourceserver.jwt.audiences`
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Verify client is requesting tokens scoped to the correct audience
|
||||||
|
- Update audience configuration if service identifier changed
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to auth-platform team if misconfiguration is system-wide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTH_CLAIM_MAPPING_FAILED (클레임 매핑 실패)
|
||||||
|
category: AUTH
|
||||||
|
error_codes: [AUTH_CLAIM_MAPPING_FAILED]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTH_CLAIM_MAPPING_FAILED (`runbook://auth/claim-mapping-failed`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 401 with `error.code=AUTH_CLAIM_MAPPING_FAILED`
|
||||||
|
- Token validated but required claims (sub, roles, tenant) missing or unexpected type
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Inspect token payload claims via logs
|
||||||
|
- Check claim extractor configuration
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Verify IdP token template includes required claims
|
||||||
|
- Update claim mapping configuration if IdP schema changed
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to auth-platform team if IdP changed claim schema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTH_ISSUER_MISMATCH (발급자 불일치)
|
||||||
|
category: AUTH
|
||||||
|
error_codes: [AUTH_ISSUER_MISMATCH]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTH_ISSUER_MISMATCH (`runbook://auth/issuer-mismatch`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 401 with `error.code=AUTH_ISSUER_MISMATCH`
|
||||||
|
- Token `iss` claim does not match configured expected issuer
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Compare token `iss` against `spring.security.oauth2.resourceserver.jwt.issuer-uri`
|
||||||
|
- Check if IdP environment changed
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Update issuer config if IdP migrated
|
||||||
|
- Reject tokens from unexpected issuers
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to platform-security if unexpected issuer detected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTH_JWKS_UNAVAILABLE (JWKS 엔드포인트 장애)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [AUTH_JWKS_UNAVAILABLE]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTH_JWKS_UNAVAILABLE (`runbook://auth/jwks-unavailable`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 503 with `error.code=AUTH_JWKS_UNAVAILABLE`
|
||||||
|
- All authentication failing; JWKS refresh attempts failing
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check IdP JWKS endpoint health: `curl -sf https://<idp-host>/.well-known/jwks.json`
|
||||||
|
- Check network connectivity from app pods to IdP
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Enable cached JWKS fallback if available
|
||||||
|
- Coordinate with IdP team for restoration
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- P1 page: IdP team immediately if JWKS endpoint unreachable > 2 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTH_KID_UNKNOWN (키 ID 미인식)
|
||||||
|
category: AUTH
|
||||||
|
error_codes: [AUTH_KID_UNKNOWN]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTH_KID_UNKNOWN (`runbook://auth/kid-unknown`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 401 with `error.code=AUTH_KID_UNKNOWN`, `retryable=true`
|
||||||
|
- Token `kid` header not present in cached JWKS
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check if IdP key rotation occurred recently
|
||||||
|
- Verify JWKS cache TTL and refresh timing
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Force JWKS cache refresh
|
||||||
|
- Confirm new key is published in IdP JWKS endpoint
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to IdP team if new kid not appearing in JWKS after 10 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — INTERNAL_AUTH_MISCONFIGURATION (공개 경로 설정 오류)
|
||||||
|
category: INTERNAL
|
||||||
|
error_codes: [INTERNAL_AUTH_MISCONFIGURATION]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: INTERNAL_AUTH_MISCONFIGURATION (`runbook://auth/public-path-misconfiguration`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 500 with `error.code=INTERNAL_AUTH_MISCONFIGURATION`
|
||||||
|
- Security filter misconfiguration detected at runtime
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check `verifyPublicPathSnapshot` output in CI
|
||||||
|
- Review recent changes to `SecurityConfig` or `application.yml` public path list
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Revert misconfigured public path change
|
||||||
|
- Run `./gradlew verifyPublicPathSnapshot` to compare snapshot
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- P1 immediate: if auth bypass is possible due to misconfiguration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTH_TOKEN_EXPIRED (토큰 만료)
|
||||||
|
category: AUTH
|
||||||
|
error_codes: [AUTH_TOKEN_EXPIRED]
|
||||||
|
severity: P3
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTH_TOKEN_EXPIRED (`runbook://auth/token-expired`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 401 with `error.code=AUTH_TOKEN_EXPIRED`
|
||||||
|
- Spike may indicate clock skew or long-lived token usage
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check `exp` claim vs server clock
|
||||||
|
- Check NTP sync on token-issuing host
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Client must refresh tokens before expiry
|
||||||
|
- Verify clock skew tolerance is configured (default 60s)
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate if spike is widespread or clock drift is confirmed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTH_TOKEN_INVALID_SIGNATURE (서명 검증 실패)
|
||||||
|
category: AUTH
|
||||||
|
error_codes: [AUTH_TOKEN_INVALID_SIGNATURE]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTH_TOKEN_INVALID_SIGNATURE (`runbook://auth/token-invalid-signature`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 401 with `error.code=AUTH_TOKEN_INVALID_SIGNATURE`
|
||||||
|
- `log_level=ERROR` — may indicate forged tokens or wrong signing key
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check if JWKS endpoint returned a new key set
|
||||||
|
- Check for token forgery attempts in logs
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Verify JWKS key IDs match token headers
|
||||||
|
- Alert security team if forgery suspected
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Immediate P1 escalation if forgery indicators present
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTH_TOKEN_MALFORMED (토큰 파싱 실패)
|
||||||
|
category: AUTH
|
||||||
|
error_codes: [AUTH_TOKEN_MALFORMED]
|
||||||
|
severity: P3
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTH_TOKEN_MALFORMED (`runbook://auth/token-malformed`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 401 responses with `error.code=AUTH_TOKEN_MALFORMED`
|
||||||
|
- Token present but fails JWT parse (not 3-part, non-base64, etc.)
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Inspect raw Authorization header value in logs
|
||||||
|
- Check if token generation tooling has a bug
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Identify source of malformed tokens
|
||||||
|
- Fix or update client token generation
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate if spike suggests infrastructure issue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTH_TOKEN_MISSING (인증 토큰 누락)
|
||||||
|
category: AUTH
|
||||||
|
error_codes: [AUTH_TOKEN_MISSING]
|
||||||
|
severity: P3
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTH_TOKEN_MISSING (`runbook://auth/token-missing`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 401 responses with `error.code=AUTH_TOKEN_MISSING`
|
||||||
|
- Client missing Authorization header or Bearer token
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check request logs for missing Authorization header
|
||||||
|
- Verify client SDK configuration
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Confirm API clients are sending Authorization header
|
||||||
|
- Check gateway/proxy configuration for header stripping
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate if widespread or affecting critical workflows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — JWT key rotation 시 인증 실패 spike
|
||||||
|
category: AUTH
|
||||||
|
error_codes: [AUTH_TOKEN_EXPIRED, AUTH_KID_UNKNOWN, AUTH_JWKS_UNAVAILABLE, AUTH_TOKEN_INVALID_SIGNATURE]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-05-22
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: JWT key rotation 시 인증 실패 spike
|
||||||
|
|
||||||
|
## 1. Trigger
|
||||||
|
|
||||||
|
이 runbook은 다음 alert에서 발동됩니다.
|
||||||
|
|
||||||
|
- alert name: `auth_401_error_rate_high` 또는 `jwks_refresh_failure_spike`
|
||||||
|
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `runbook_link`, `dependency_name`
|
||||||
|
- 임계: 401 error rate > 5% 5분 지속 OR JWKS refresh failure count > 10건/분
|
||||||
|
|
||||||
|
## 2. First Response (5분 이내)
|
||||||
|
|
||||||
|
### Step 1 — 확인
|
||||||
|
1. JWKS endpoint health check: `curl -sf https://<idp-host>/.well-known/jwks.json | jq '.keys | length'`
|
||||||
|
2. log query에서 `error.code` 분포 확인 — `AUTH_KID_UNKNOWN` 비중이 높으면 rotation 원인 강력 시사
|
||||||
|
3. IdP rotation schedule 확인 (직전 24h 내 rotation 이벤트가 있었는지)
|
||||||
|
|
||||||
|
### Step 2 — 임시 격리
|
||||||
|
- JWKS cache TTL을 짧게(예: 60s) 강제하여 새 kid 전파 가속
|
||||||
|
- 새 kid가 JWKS에 publish되어 있는지 확인. 누락이면 IdP에 republish 요청
|
||||||
|
|
||||||
|
## 3. Diagnosis
|
||||||
|
|
||||||
|
- log query (Loki/CloudWatch): `{service="auth"} | error.category="AUTH" | dependency_name="jwks-endpoint"`
|
||||||
|
- metric panel: `auth_jwks_cache_hit_ratio`, `auth_jwks_refresh_failure_total`, `auth_kid_unknown_total`
|
||||||
|
- trace: 실패한 request 1건에서 `traceId` 추출 → IdP outbound span 확인
|
||||||
|
- 가능한 원인:
|
||||||
|
- 새 kid가 JWKS에 publish되기 전 token 발급 → 24h overlap window 안에 있는지 확인
|
||||||
|
- JWKS endpoint 장애 (5xx, timeout) → IdP status page 확인
|
||||||
|
- 시계 skew로 인한 만료 오판 → NTP sync 상태 확인
|
||||||
|
|
||||||
|
## 4. Mitigation
|
||||||
|
|
||||||
|
- 단기: old kid를 임시 재허용 (rollback). overlap window를 48h로 일시 확장
|
||||||
|
- IdP에 새 JWKS publish 재시도 요청
|
||||||
|
- 장기: rotation 절차에 "publish → 24h 대기 → switch" 단계 강제. observability에 kid 분포 metric 추가
|
||||||
|
|
||||||
|
## 5. Escalation
|
||||||
|
|
||||||
|
- P2 → P1 격상 조건: 401 error rate > 20% 또는 다중 tenant에 동시 발생
|
||||||
|
- 다음 on-call로 page: 10분 내 회복 안 되면 IdP team 또는 platform-security team page
|
||||||
|
|
||||||
|
## 6. Recovery / Verification
|
||||||
|
|
||||||
|
- 회복 확인 metric: `auth_401_error_rate < 1%` 5분 지속, `AUTH_KID_UNKNOWN` 건수 0
|
||||||
|
- post-incident:
|
||||||
|
- rotation 절차 RCA 작성
|
||||||
|
- JWKS overlap window 정책 문서 업데이트
|
||||||
|
- kid 분포 dashboard 영구화
|
||||||
|
|
||||||
|
## 7. Related
|
||||||
|
|
||||||
|
- error-codes.yaml rows: `AUTH_TOKEN_EXPIRED`, `AUTH_KID_UNKNOWN`, `AUTH_JWKS_UNAVAILABLE`, `AUTH_TOKEN_INVALID_SIGNATURE`
|
||||||
|
- metrics.yaml: `auth_jwks_cache_hit_ratio`, `auth_jwks_refresh_failure_total`
|
||||||
|
- 관련 branch: [[feature-security-operational-baseline]]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 IdP 종류·rotation 정책·JWKS endpoint URL·dashboard 링크로 보강 필요.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — cross-tenant 접근 시도 감지
|
||||||
|
category: AUTHZ
|
||||||
|
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION, AUTHZ_TENANT_MISMATCH]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-05-22
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: cross-tenant 접근 시도 감지
|
||||||
|
|
||||||
|
## 1. Trigger
|
||||||
|
|
||||||
|
이 runbook은 다음 alert에서 발동됩니다.
|
||||||
|
|
||||||
|
- alert name: `authz_cross_tenant_violation` 또는 `authz_403_spike`
|
||||||
|
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `principal_id_pseudonymized`, `tenant_id`, `runbook_link`
|
||||||
|
- 임계:
|
||||||
|
- P2: 403 with `error.code=AUTHZ_TENANT_MISMATCH` > 10건/5분
|
||||||
|
- P1 격상: 동일 principal에서 3개 이상 tenant 시도 OR 5분 내 100건 초과
|
||||||
|
|
||||||
|
## 2. First Response (5분 이내)
|
||||||
|
|
||||||
|
### Step 1 — 확인
|
||||||
|
1. log query로 위반 principal 식별 (pseudonymized): `error.code=AUTHZ_TENANT_MISMATCH`
|
||||||
|
2. principal의 정상 tenant scope 확인 (IdP claim 또는 entitlement table)
|
||||||
|
3. `CROSS_TENANT_ADMIN` capability 보유 여부 확인 — 보유자라면 false positive 가능성
|
||||||
|
|
||||||
|
### Step 2 — 임시 격리
|
||||||
|
- 명백한 위반 패턴이면 principal session 강제 만료 (token revocation list 추가)
|
||||||
|
- security incident channel 통보 (`#sec-incident`)
|
||||||
|
- 위반 request의 source IP / user-agent 기록
|
||||||
|
|
||||||
|
## 3. Diagnosis
|
||||||
|
|
||||||
|
- log query: `{service="api"} | error.category="AUTHZ" | principal_id_pseudonymized="<hash>"`
|
||||||
|
- metric panel: `authz_denied_total{reason="tenant_mismatch"}`, `authz_principal_tenant_distribution`
|
||||||
|
- trace: 위반 request의 `traceId`로 호출 chain 확인. token claim의 `tenant_id`와 요청 path의 `tenant_id` 비교
|
||||||
|
- 가능한 원인:
|
||||||
|
- account takeover (계정 탈취) → 즉시 session revoke + 비밀번호 reset 요구
|
||||||
|
- client bug (잘못된 tenant id 전송) → product team에 통보
|
||||||
|
- 정상 admin operation 누락된 capability → entitlement 보정
|
||||||
|
|
||||||
|
## 4. Mitigation
|
||||||
|
|
||||||
|
- 단기: principal session revoke, source IP rate-limit 강화
|
||||||
|
- 위반이 client bug면 client patch release 협조
|
||||||
|
- 장기: tenant boundary 검증 layer를 controller가 아닌 repository 진입점에서 강제 ([[feature-repository-access-permission-contract]])
|
||||||
|
|
||||||
|
## 5. Escalation
|
||||||
|
|
||||||
|
- 다음 on-call로 page: 보안 incident channel 즉시 page. 5분 내 security on-call 응답 없으면 CISO escalation
|
||||||
|
- legal/compliance 통보 필요 여부 판단 (개인정보 noted시)
|
||||||
|
|
||||||
|
## 6. Recovery / Verification
|
||||||
|
|
||||||
|
- 회복 확인 metric: `AUTHZ_TENANT_MISMATCH` 건수 정상 baseline 복귀
|
||||||
|
- post-incident:
|
||||||
|
- account takeover면 forensics 수행 + audit log 보존
|
||||||
|
- cross-tenant 검증 unit test 추가
|
||||||
|
- 위반 패턴 detection rule 영구화
|
||||||
|
|
||||||
|
## 7. Related
|
||||||
|
|
||||||
|
- error-codes.yaml rows: `AUTHZ_INSUFFICIENT_PERMISSION`, `AUTHZ_TENANT_MISMATCH`
|
||||||
|
- metrics.yaml: `authz_denied_total`, `authz_principal_tenant_distribution`
|
||||||
|
- 관련 branch: [[feature-tenant-context-policy]], [[feature-repository-access-permission-contract]]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 tenant 모델·capability 정의·security team 연락 체계로 보강 필요.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTHZ_INSUFFICIENT_PERMISSION (권한 부족)
|
||||||
|
category: AUTHZ
|
||||||
|
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION]
|
||||||
|
severity: P3
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTHZ_INSUFFICIENT_PERMISSION (`runbook://authz/insufficient-permission`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 403 with `error.code=AUTHZ_INSUFFICIENT_PERMISSION`
|
||||||
|
- Valid token but missing required role or permission
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check user's assigned roles in IdP
|
||||||
|
- Review endpoint's required permission annotation
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Grant correct role/permission to user
|
||||||
|
- Verify endpoint permission requirement is correct
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to access-management team if bulk users affected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — AUTHZ_TENANT_MISMATCH (테넌트 cross-access 시도)
|
||||||
|
category: AUTHZ
|
||||||
|
error_codes: [AUTHZ_TENANT_MISMATCH]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: AUTHZ_TENANT_MISMATCH (`runbook://authz/tenant-mismatch`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 403 with `error.code=AUTHZ_TENANT_MISMATCH`
|
||||||
|
- `log_level=ERROR` — cross-tenant access attempt detected
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Extract `traceId`, check `X-Tenant-Id` vs token tenant claim
|
||||||
|
- Determine if this is misconfigured client or intentional attack
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Block repeat offenders at gateway level
|
||||||
|
- Alert security team for investigation
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- P1 if confirmed malicious cross-tenant access attempt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — CACHE_STAMPEDE_LOCK_TIMEOUT (캐시 스탬피드 락 타임아웃)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [CACHE_STAMPEDE_LOCK_TIMEOUT]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: CACHE_STAMPEDE_LOCK_TIMEOUT (`runbook://cache/stampede-lock-timeout`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 503 with `error.code=CACHE_STAMPEDE_LOCK_TIMEOUT`
|
||||||
|
- Multiple concurrent cache misses on same key; lock contention
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check cache hit ratio metrics
|
||||||
|
- Identify cache keys with high miss rates
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Verify stampede lock TTL is configured appropriately
|
||||||
|
- Pre-warm cache for high-traffic keys on startup
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate if backend load spike accompanies stampede
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — CACHE_UNAVAILABLE (캐시 연결 불가)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [CACHE_UNAVAILABLE]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: CACHE_UNAVAILABLE (`runbook://cache/unavailable`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 503 with `error.code=CACHE_UNAVAILABLE`
|
||||||
|
- Redis connection errors in logs
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check Redis cluster health
|
||||||
|
- Verify network connectivity from app to Redis
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Check Redis sentinel/cluster status
|
||||||
|
- Enable cache degradation path if configured for optional caches
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- P1 if required cache is down and no degradation path exists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DB_DEADLOCK (데드락)
|
||||||
|
category: CONFLICT
|
||||||
|
error_codes: [DB_DEADLOCK]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DB_DEADLOCK (`runbook://db/deadlock`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 409 with `error.code=DB_DEADLOCK`
|
||||||
|
- SQLState 40P01 in Postgres logs
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check `pg_locks` and `pg_stat_activity` during deadlock
|
||||||
|
- Identify conflicting transaction lock order
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Client should retry (retryable=true)
|
||||||
|
- Fix lock ordering in code if recurring
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to DBA if deadlock rate is sustained > 1% of transactions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DB_IDLE_IN_TX_TIMEOUT (트랜잭션 idle 타임아웃)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [DB_IDLE_IN_TX_TIMEOUT]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DB_IDLE_IN_TX_TIMEOUT (`runbook://db/idle-in-tx-timeout`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 503 with `error.code=DB_IDLE_IN_TX_TIMEOUT`
|
||||||
|
- SQLState 25P03; transaction held open too long without activity
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check `idle_in_transaction_session_timeout` Postgres setting
|
||||||
|
- Look for application-level long-running transaction holders
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Reduce transaction scope in application code
|
||||||
|
- Verify `spring.jpa.properties.hibernate.connection.timeout` is bounded
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to DBA if connection pool exhaustion results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DB_QUERY_CANCELED (쿼리 취소)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [DB_QUERY_CANCELED]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DB_QUERY_CANCELED (`runbook://db/query-canceled`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 503 with `error.code=DB_QUERY_CANCELED`
|
||||||
|
- SQLState 57014; query exceeds statement timeout
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check `statement_timeout` in Postgres
|
||||||
|
- Identify slow queries in `pg_stat_statements`
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Optimize slow query or add index
|
||||||
|
- Adjust statement timeout if query is legitimately long
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to DBA for query optimization if recurring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DB_SERIALIZATION_FAILURE (직렬화 실패)
|
||||||
|
category: CONFLICT
|
||||||
|
error_codes: [DB_SERIALIZATION_FAILURE]
|
||||||
|
severity: P3
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DB_SERIALIZATION_FAILURE (`runbook://db/serialization-failure`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 409 with `error.code=DB_SERIALIZATION_FAILURE`
|
||||||
|
- SQLState 40001; high concurrent transaction contention
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check DB transaction isolation level
|
||||||
|
- Identify hot rows / hot tables under high concurrency
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Client should retry with exponential backoff (retryable=true)
|
||||||
|
- Optimize transaction scope if spike is sustained
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to DBA if sustained serialization failure rate > 5%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DB_UNAVAILABLE (데이터베이스 연결 불가)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [DB_UNAVAILABLE]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DB_UNAVAILABLE (`runbook://db/unavailable`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 503 with `error.code=DB_UNAVAILABLE`
|
||||||
|
- SQLState 08* connection errors in logs
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check DB server health and connection pool exhaustion
|
||||||
|
- Review network connectivity from app pods to DB
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Check DB primary health; failover to replica if available
|
||||||
|
- Drain connection pool and reconnect
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- P1: immediate if DB primary is down
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DEPENDENCY_4XX_CLIENT (업스트림 클라이언트 오류)
|
||||||
|
category: PERMANENT_DEPENDENCY
|
||||||
|
error_codes: [DEPENDENCY_4XX_CLIENT]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DEPENDENCY_4XX_CLIENT (`runbook://dependency/4xx-client`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 502 with `error.code=DEPENDENCY_4XX_CLIENT`
|
||||||
|
- Upstream returned 401/403/400 — credential, scope, or request format issue
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check upstream response body in logs for error detail
|
||||||
|
- Verify API credentials and scopes are valid
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Rotate credentials if expired
|
||||||
|
- Fix request format if API contract changed
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to upstream API owner if contract change is suspected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DEPENDENCY_5XX_SERVER (업스트림 서버 오류)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [DEPENDENCY_5XX_SERVER]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DEPENDENCY_5XX_SERVER (`runbook://dependency/5xx-server`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 502 with `error.code=DEPENDENCY_5XX_SERVER`
|
||||||
|
- Upstream returned 5xx; transient server-side failure
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check `dependency_name` tag for which upstream is failing
|
||||||
|
- Review upstream service status page
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Client should retry (retryable=true)
|
||||||
|
- Monitor upstream recovery
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- P1 if critical upstream is in sustained 5xx state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DEPENDENCY_CIRCUIT_OPEN (서킷 브레이커 개방)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [DEPENDENCY_CIRCUIT_OPEN]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DEPENDENCY_CIRCUIT_OPEN (`runbook://dependency/circuit-open`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 503 with `error.code=DEPENDENCY_CIRCUIT_OPEN`
|
||||||
|
- Circuit breaker (Resilience4j) in OPEN state for a dependency
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check Resilience4j circuit breaker metrics for the dependency
|
||||||
|
- Check upstream health; circuit opens after failure threshold breached
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Wait for circuit half-open probe (automatic after wait duration)
|
||||||
|
- Resolve upstream issue to allow circuit to close
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- P1 if circuit remains open > 5 minutes on a critical dependency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DEPENDENCY_CONNECT_FAILED (외부 의존성 연결 실패)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [DEPENDENCY_CONNECT_FAILED]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DEPENDENCY_CONNECT_FAILED (`runbook://dependency/connect-failed`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 503 with `error.code=DEPENDENCY_CONNECT_FAILED`
|
||||||
|
- TCP connection refused or network unreachable to upstream
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check `dependency_name` tag for which upstream is unreachable
|
||||||
|
- Verify network path and firewall rules
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Check upstream service availability
|
||||||
|
- Verify service discovery / DNS resolution
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- P1 if upstream is a critical service dependency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DEPENDENCY_DNS_FAILED (DNS 조회 실패)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [DEPENDENCY_DNS_FAILED]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DEPENDENCY_DNS_FAILED (`runbook://dependency/dns-failed`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 503 with `error.code=DEPENDENCY_DNS_FAILED`
|
||||||
|
- DNS resolution failure for upstream hostname
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Test DNS resolution from app pod: `nslookup <upstream-host>`
|
||||||
|
- Check cluster DNS (CoreDNS) health
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Verify upstream hostname configuration
|
||||||
|
- Check CoreDNS / cluster DNS health
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- P1 if cluster DNS is degraded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DEPENDENCY_TIMEOUT (외부 의존성 타임아웃)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [DEPENDENCY_TIMEOUT]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DEPENDENCY_TIMEOUT (`runbook://dependency/timeout`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 504 with `error.code=DEPENDENCY_TIMEOUT`
|
||||||
|
- Upstream service did not respond within configured timeout (default: global 10s)
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check `dependency_name` in log for which upstream is timing out
|
||||||
|
- Review upstream service latency metrics
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Check upstream service health
|
||||||
|
- Verify timeout settings match SLA expectations
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- P1 if critical upstream is timing out at scale
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — 외부 의존성 unavailable
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [DEPENDENCY_TIMEOUT, DEPENDENCY_CONNECT_FAILED, DEPENDENCY_DNS_FAILED, DEPENDENCY_CIRCUIT_OPEN, DEPENDENCY_5XX_SERVER, CACHE_UNAVAILABLE, DB_UNAVAILABLE]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-05-22
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: 외부 의존성 unavailable
|
||||||
|
|
||||||
|
## 1. Trigger
|
||||||
|
|
||||||
|
이 runbook은 다음 alert에서 발동됩니다.
|
||||||
|
|
||||||
|
- alert name: `dependency_error_rate_critical` 또는 `circuit_breaker_open`
|
||||||
|
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `dependency_name`, `dependency_kind`(required|optional), `runbook_link`
|
||||||
|
- 임계:
|
||||||
|
- P1: required dependency의 error rate > 50% 1분 OR circuit_open state 활성
|
||||||
|
- P2: optional dependency degraded (fail-open으로 동작 중)
|
||||||
|
|
||||||
|
## 2. First Response (5분 이내)
|
||||||
|
|
||||||
|
### Step 1 — 확인
|
||||||
|
1. `dependency_name` 별 status page 확인 (외부 SaaS면 vendor status, internal이면 해당 service dashboard)
|
||||||
|
2. log query로 실패 패턴 확인: timeout / connect / DNS / 5xx 중 어떤 모드인지
|
||||||
|
3. runtime-health Dependency Matrix에서 required vs optional 분류 확인
|
||||||
|
4. circuit breaker state 확인 (Resilience4j metric)
|
||||||
|
|
||||||
|
### Step 2 — 임시 격리
|
||||||
|
- required dep이면 readiness probe로 traffic 차단 (회복 대기) — cascade failure 방지
|
||||||
|
- optional dep이면 fail-open with degraded mode 확인. degraded banner를 client에 노출
|
||||||
|
- DNS failure면 resolver/coredns 상태 확인. cache 강제 flush 검토
|
||||||
|
|
||||||
|
## 3. Diagnosis
|
||||||
|
|
||||||
|
- log query: `{service="app"} | dependency_name="<name>" | stats count by error.code`
|
||||||
|
- metric panel:
|
||||||
|
- `resilience4j_circuitbreaker_state{name="<name>"}`
|
||||||
|
- `resilience4j_retry_calls_total{kind="failed_without_retry"}`
|
||||||
|
- `hikaricp_connections_active`, `hikaricp_connections_pending` (DB_UNAVAILABLE)
|
||||||
|
- `http_client_requests_seconds_count{outcome="SERVER_ERROR"}`
|
||||||
|
- trace: 실패 request의 outbound span에서 timeout/connect/DNS 분류, target endpoint 확인
|
||||||
|
- 가능한 원인:
|
||||||
|
- vendor outage → status page 확인, 회복 대기
|
||||||
|
- 네트워크 문제 (DNS, security group, NAT) → infra team 확인
|
||||||
|
- connection pool 고갈 (Hikari) → pool size/timeout 점검
|
||||||
|
- circuit breaker open 후 half-open 전환 실패 → 수동 reset 검토
|
||||||
|
- retry-storm으로 인한 self-DoS → retry budget 축소
|
||||||
|
|
||||||
|
## 4. Mitigation
|
||||||
|
|
||||||
|
- 단기: required면 회복 대기 + traffic 차단, optional이면 degraded mode로 유지
|
||||||
|
- pool 고갈이면 일시 pool size 상향 + leak detection 활성화
|
||||||
|
- circuit이 stuck이면 수동 reset (`actuator/circuitbreakerevents`)
|
||||||
|
- 장기: retry budget·timeout·circuit 임계 재조정, fallback path 보강, vendor SLA 재협상
|
||||||
|
|
||||||
|
## 5. Escalation
|
||||||
|
|
||||||
|
- 다음 on-call로 page: required dep 5분 내 회복 안 되면 외부 dep team 또는 vendor에 page
|
||||||
|
- 다중 dep 동시 장애면 incident commander 호출 (네트워크 전반 문제 의심)
|
||||||
|
|
||||||
|
## 6. Recovery / Verification
|
||||||
|
|
||||||
|
- 회복 확인 metric: dependency error rate < 1% 5분 지속, circuit_breaker_state = CLOSED, pool utilization 정상
|
||||||
|
- post-incident:
|
||||||
|
- vendor postmortem 요청 (외부 SaaS면)
|
||||||
|
- timeout/retry/circuit 설정 재검토
|
||||||
|
- degraded mode가 사용자 경험에 미친 영향 측정
|
||||||
|
- chaos test에 해당 시나리오 추가
|
||||||
|
|
||||||
|
## 7. Related
|
||||||
|
|
||||||
|
- error-codes.yaml rows: `DEPENDENCY_TIMEOUT`, `DEPENDENCY_CONNECT_FAILED`, `DEPENDENCY_DNS_FAILED`, `DEPENDENCY_CIRCUIT_OPEN`, `DEPENDENCY_5XX_SERVER`, `CACHE_UNAVAILABLE`, `DB_UNAVAILABLE`
|
||||||
|
- metrics.yaml: `resilience4j_circuitbreaker_state`, `hikaricp_connections_active`, `http_client_requests_seconds_count`
|
||||||
|
- 관련 branch: [[feature-outbound-http-client-baseline]], [[feature-persistence-failure-baseline]]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 dependency 목록·required/optional 분류·vendor 연락 체계·circuit/timeout 임계로 보강 필요.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — DOWNLOAD_STREAMING_FAILURE (스트리밍 다운로드 실패)
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [DOWNLOAD_STREAMING_FAILURE]
|
||||||
|
severity: P2
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-15
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: DOWNLOAD_STREAMING_FAILURE (`runbook://file/download-streaming-failure`)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- HTTP 503 with `error.code=DOWNLOAD_STREAMING_FAILURE`
|
||||||
|
- Streaming response truncated; backpressure or timeout (60s / 100MB limit)
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- Check streaming response timeout configuration
|
||||||
|
- Review download size vs 100MB limit
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
- Verify storage backend is reachable
|
||||||
|
- Check for network congestion on download path
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
- Escalate to infra if storage backend is degraded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — 5xx Internal error spike
|
||||||
|
category: INTERNAL
|
||||||
|
error_codes: [INTERNAL_ERROR, INTERNAL_AUTH_MISCONFIGURATION, JVM_OOM]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-05-22
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: 5xx Internal error spike
|
||||||
|
|
||||||
|
## 1. Trigger
|
||||||
|
|
||||||
|
이 runbook은 다음 alert에서 발동됩니다.
|
||||||
|
|
||||||
|
- alert name: `http_5xx_error_rate_critical`
|
||||||
|
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `request_id`, `traceId`, `runbook_link`
|
||||||
|
- 임계: 5xx error rate > 5% 5분 지속 OR > 10% 1분
|
||||||
|
|
||||||
|
## 2. First Response (5분 이내)
|
||||||
|
|
||||||
|
### Step 1 — 확인
|
||||||
|
1. 가장 최근 deploy 시각 확인 (CI/CD dashboard, artifact registry digest)
|
||||||
|
2. JVM metric 확인: heap usage, GC pause, CPU, thread count
|
||||||
|
3. log에서 실패 request 1건 추출 → `request_id`, `traceId` 확보
|
||||||
|
4. error.code 분포 확인: `INTERNAL_ERROR` vs `JVM_OOM` vs `INTERNAL_AUTH_MISCONFIGURATION`
|
||||||
|
|
||||||
|
### Step 2 — 임시 격리
|
||||||
|
- 직전 deploy가 의심되면 즉시 rollback (artifact registry에서 직전 image digest pin)
|
||||||
|
- OOM 패턴이면 affected pod evict → ASG/HPA로 replacement 유도
|
||||||
|
- LB에서 unhealthy pod 격리 (readiness probe failure 유도)
|
||||||
|
|
||||||
|
## 3. Diagnosis
|
||||||
|
|
||||||
|
- log query: `{service="app"} | http.status>=500 | stats count by error.code`
|
||||||
|
- metric panel: `jvm_memory_used_bytes{area="heap"}`, `jvm_gc_pause_seconds`, `process_cpu_seconds_total`, `http_server_requests_seconds_count{status=~"5.."}`
|
||||||
|
- trace: 실패 request의 `traceId`로 span chain 확인 → stack trace에서 root exception 추출
|
||||||
|
- heap dump 위치: `/var/tmp/heap/heapdump-<pid>.hprof` (JVM ergonomics: `-XX:MaxRAMPercentage=75 -XX:+HeapDumpOnOutOfMemoryError`)
|
||||||
|
- 가능한 원인:
|
||||||
|
- 직전 deploy의 회귀 버그 → rollback
|
||||||
|
- JVM OOM (메모리 leak 또는 부하 증가) → heap dump 분석
|
||||||
|
- 외부 의존성 설정 오류 (`INTERNAL_AUTH_MISCONFIGURATION`) → config secret 확인
|
||||||
|
- thread starvation (pool 고갈) → thread dump (`jstack <pid>`)
|
||||||
|
|
||||||
|
## 4. Mitigation
|
||||||
|
|
||||||
|
- 단기: 직전 deploy rollback, OOM pod replacement, traffic 일시 감소(scale-out 또는 rate-limit 강화)
|
||||||
|
- config 오류면 secret/configmap rollback
|
||||||
|
- 장기: heap dump 기반 leak 수정, capacity planning 재검토
|
||||||
|
|
||||||
|
## 5. Escalation
|
||||||
|
|
||||||
|
- 다음 on-call로 page: 10분 내 회복 안 되면 incident commander 호출, severity 1 incident 선언
|
||||||
|
- 데이터 손상 의심되면 DBA team page
|
||||||
|
|
||||||
|
## 6. Recovery / Verification
|
||||||
|
|
||||||
|
- 회복 확인 metric: 5xx rate < 0.5% 5분 지속, JVM heap usage < 70%, GC pause p99 < 500ms
|
||||||
|
- post-incident:
|
||||||
|
- rollback 원인 RCA 작성 (배포 게이트 강화 필요 여부)
|
||||||
|
- heap dump 분석 결과 공유
|
||||||
|
- JVM ergonomics(`-XX:MaxRAMPercentage`) 재검토
|
||||||
|
- rollback 자동화 절차 점검
|
||||||
|
|
||||||
|
## 7. Related
|
||||||
|
|
||||||
|
- error-codes.yaml rows: `INTERNAL_ERROR`, `INTERNAL_AUTH_MISCONFIGURATION`, `JVM_OOM`
|
||||||
|
- metrics.yaml: `jvm_memory_used_bytes`, `jvm_gc_pause_seconds`, `http_server_requests_seconds_count`
|
||||||
|
- 관련 branch: [[feature-operational-error-observability-foundation]], [[feature-container-runtime-contract]]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 deploy 파이프라인·heap dump 보관 경로·rollback 자동화 명령으로 보강 필요.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — background job dead letter
|
||||||
|
category: INTERNAL
|
||||||
|
error_codes: [JOB_DEAD_LETTER]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-13
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: background job dead letter (`runbook://job/dead-letter`)
|
||||||
|
|
||||||
|
## 1. Trigger
|
||||||
|
|
||||||
|
이 runbook은 다음 alert에서 발동됩니다.
|
||||||
|
|
||||||
|
- alert name: `job_dead_letter`
|
||||||
|
- alert payload 필수 field: `error.code=JOB_DEAD_LETTER`, `job_name`, `correlation_id`, `runbook_link`
|
||||||
|
- 임계: `job.dlq.total` > 0 for 5m (p1) — retry 소진 후 DLQ 진입은 자동 회복이 없으므로 점검 대상
|
||||||
|
|
||||||
|
## 2. First Response (5분 이내)
|
||||||
|
|
||||||
|
### Step 1 — 확인
|
||||||
|
1. ERROR log에서 `JOB_DEAD_LETTER` 라인 확인: `job_name`, 최종 실패 원인 예외, `correlation_id` 추출
|
||||||
|
2. `job.retry.total{outcome=EXHAUSTED}` 추이로 DLQ 유입 규모 파악
|
||||||
|
3. DLQ 적재 위치(향후 retry carrier 확정 시 DB 테이블/큐) 확인 — 현재 skeleton은 vocabulary 단계
|
||||||
|
|
||||||
|
### Step 2 — 임시 격리
|
||||||
|
- DLQ는 max attempts(3) 소진의 최종 상태 — 자동 재시도 없음, 수동 개입 필수
|
||||||
|
- 비즈니스 크리티컬 job이면 §4의 수동 처분(재처리 또는 폐기)을 우선 수행
|
||||||
|
|
||||||
|
## 3. Diagnosis
|
||||||
|
|
||||||
|
- log query: `{service="app"} | error.code="JOB_DEAD_LETTER" | stats count by job_name`
|
||||||
|
- metric panel: `job.dlq.total{job_name}`, `job.retry.total{job_name, outcome}`
|
||||||
|
- 최종 실패 원인 분류:
|
||||||
|
- poison input(직렬화/계약 위반) → 입력 결함, 재처리해도 실패 — 수정 후 재처리 또는 폐기
|
||||||
|
- 외부 의존성 장기 outage 중 attempts 소진 → 의존성 회복 후 재처리로 해결 가능
|
||||||
|
- non-transient error(권한/도메인/스키마)인데 retry된 경우 → 분류기 보강 필요(WAF-REL05-C3: 즉시 DLQ가 정답)
|
||||||
|
|
||||||
|
## 4. Mitigation (수동 처분 — 둘 중 하나)
|
||||||
|
|
||||||
|
- **재처리 (기본)**: 원인 해소 후 해당 job을 다시 enqueue. 소비자는 멱등(idempotencyKey dedupe) 의무가 있으므로 중복 처리 안전
|
||||||
|
- **폐기 (영구)**: 작업이 더 이상 유효하지 않으면 DLQ에서 제거. ⚠ 비즈니스 오너 승인 후에만 수행하고 incident 기록에 남김
|
||||||
|
- 장기: poison input 재발 방지(입력 계약 테스트 보강), non-transient error는 retry 없이 즉시 DLQ로 분류
|
||||||
|
|
||||||
|
## 5. Escalation
|
||||||
|
|
||||||
|
- 처분 판단(재처리 vs 폐기)이 불가하면 해당 job의 비즈니스 오너에게 escalate
|
||||||
|
- DLQ 누적이 특정 `job_name`에 집중되면 해당 job 코드 오너에게 page
|
||||||
|
|
||||||
|
## 6. Recovery / Verification
|
||||||
|
|
||||||
|
- 회복 확인: `job.dlq.total` 증가 멈춤, 재처리분의 소비자 dedupe 동작 확인
|
||||||
|
- post-incident: DLQ 원인 분류 기록, 같은 원인의 재발 방지 테스트 추가
|
||||||
|
|
||||||
|
## 7. Related
|
||||||
|
|
||||||
|
- error-codes.yaml rows: `JOB_DEAD_LETTER` (INTERNAL, 500, retryable=false)
|
||||||
|
- metrics.yaml: `job.dlq.total{job_name}`, `job.retry.total{job_name, outcome=DLQ}`
|
||||||
|
- 코드: `app-bootstrap` `async/BackgroundJobMetrics`(retry/DLQ vocabulary 기록 seam — D2/D4)
|
||||||
|
- 관련 runbook: [[job-executor-rejected]], [[job-timeout]], [[outbox-dead-letter]]
|
||||||
|
- 관련 branch: [[feature-background-job-async-contract]] (D4 retry/DLQ vocabulary SSOT — outbox/outbound가 consume)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. retry carrier(Spring Retry / Resilience4j / 자체) 확정 후 DLQ 저장소·재처리 절차 보강 필요.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
title: Runbook — async executor rejected
|
||||||
|
category: TRANSIENT_DEPENDENCY
|
||||||
|
error_codes: [JOB_EXECUTOR_REJECTED]
|
||||||
|
severity: P1
|
||||||
|
owner: oncall
|
||||||
|
last_updated: 2026-06-13
|
||||||
|
status: stub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runbook: async executor rejected (`runbook://job/executor-rejected`)
|
||||||
|
|
||||||
|
## 1. Trigger
|
||||||
|
|
||||||
|
이 runbook은 다음 alert에서 발동됩니다.
|
||||||
|
|
||||||
|
- alert name: `executor_rejected`
|
||||||
|
- alert payload 필수 field: `error.code=JOB_EXECUTOR_REJECTED`, `executor_name`, `policy`, `runbook_link`
|
||||||
|
- 임계: `executor.rejected.total` > 0 for 1m (p1) — bounded pool이 saturation으로 task를 거부
|
||||||
|
- 보조 신호: `executor.saturation` gauge > queue capacity의 80% for 5m (p2)
|
||||||
|
|
||||||
|
## 2. First Response (5분 이내)
|
||||||
|
|
||||||
|
### Step 1 — 확인
|
||||||
|
1. ERROR log에서 `JOB_EXECUTOR_REJECTED` 라인 확인: `executor_name`, `policy=AbortPolicy`, `queue_size` 추출
|
||||||
|
2. `executor.saturation` 패널에서 큐 점유율 추이 확인 — 일시적 burst인지 지속 saturation인지 판별
|
||||||
|
3. 동시 유입 원인 파악: 신규 배포 / 트래픽 spike / 다운스트림 지연으로 worker가 장기 점유되는지
|
||||||
|
|
||||||
|
### Step 2 — 임시 격리
|
||||||
|
- AbortPolicy 거부는 호출부에 `RejectedExecutionException`으로 surface됨 — fire-and-forget `@Async` 호출이면 호출부의 async-exception 처리(log/metric)로 흡수됐는지 확인
|
||||||
|
- 지속 saturation이면 유입 측(트래픽/스케줄러 빈도)을 우선 감속
|
||||||
|
|
||||||
|
## 3. Diagnosis
|
||||||
|
|
||||||
|
- log query: `{service="app"} | error.code="JOB_EXECUTOR_REJECTED" | stats count by executor_name`
|
||||||
|
- metric panel: `executor.saturation{executor_name}`, `executor.rejected.total{executor_name, policy}`
|
||||||
|
- 가능한 원인 우선순위:
|
||||||
|
- 다운스트림 의존성 지연 → worker가 반납되지 않아 큐 포화 (가장 흔함)
|
||||||
|
- 트래픽 spike → 정상 부하 한계 초과
|
||||||
|
- pool 과소 설정 (`APP_ASYNC_EXECUTOR_*`)
|
||||||
|
- non-idempotent 작업이 retry로 누적
|
||||||
|
|
||||||
|
## 4. Mitigation
|
||||||
|
|
||||||
|
- 단기: 유입 감속(상위 rate-limit / 스케줄러 interval 확대) 또는 다운스트림 의존성 회복
|
||||||
|
- pool 재조정(restart-only): `APP_ASYNC_EXECUTOR_CORE_SIZE` / `APP_ASYNC_EXECUTOR_MAX_SIZE` / `APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`
|
||||||
|
— ⚠ queue를 무한정 키우지 말 것(unbounded 금지, D7). 부하테스트로 수치 검증 후 변경
|
||||||
|
- CallerRunsPolicy로의 전환은 use-case 차원의 명시적 결정 필요(request thread latency 침식 — TPE-JDK21-C6)
|
||||||
|
|
||||||
|
## 5. Escalation
|
||||||
|
|
||||||
|
- 다운스트림 의존성 장애가 근본 원인이면 해당 의존성 오너에게 escalate
|
||||||
|
- pool 재조정으로도 saturation이 지속되면 용량 계획(capacity planning) 오너에게 page
|
||||||
|
|
||||||
|
## 6. Recovery / Verification
|
||||||
|
|
||||||
|
- 회복 확인: `executor.rejected.total` 증가 멈춤, `executor.saturation` < 80% 정상화
|
||||||
|
- 거부된 작업의 재처리 경로(멱등 retry / 다음 스케줄 cycle) 정상 동작 확인
|
||||||
|
|
||||||
|
## 7. Related
|
||||||
|
|
||||||
|
- error-codes.yaml rows: `JOB_EXECUTOR_REJECTED` (TRANSIENT_DEPENDENCY, 503, retryable=true, retry_after 5s)
|
||||||
|
- metrics.yaml: `executor.rejected.total{executor_name, policy}`, `executor.saturation{executor_name}`
|
||||||
|
- 코드: `app-bootstrap` `async/AsyncExecutorConfig`(bounded executor), `async/LoggingAbortPolicy`(reject log+metric), `async/BackgroundJobMetrics`
|
||||||
|
- env: `APP_ASYNC_EXECUTOR_CORE_SIZE` / `APP_ASYNC_EXECUTOR_MAX_SIZE` / `APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`
|
||||||
|
- 관련 runbook: [[job-timeout]], [[job-dead-letter]]
|
||||||
|
- 관련 branch: [[feature-background-job-async-contract]] (D7 saturation policy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 부하 프로파일·alert 채널·pool 수치 확정 시 보강 필요.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user