feat: redis, fileserver, httpclient 런타임 시점 구현 추가
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,145 @@
|
|||||||
|
# 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: 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: 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
|
||||||
@@ -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,202 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
|
||||||
|
readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
|
||||||
|
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
|
||||||
|
readonly EXPECTED_GATE_COUNT=19
|
||||||
|
|
||||||
|
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
|
||||||
|
printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
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}"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ! grep -RqsE -- "tasks\\.register\\(['\"]${ref}['\"]" "${REPO_ROOT}/src" \
|
||||||
|
--include='build.gradle'; then
|
||||||
|
failures+=("gate '${id}' references unregistered Gradle task '${ref}'")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
gradle-plugin-task)
|
||||||
|
plugin="${ref%@*}"
|
||||||
|
task="${ref#*@}"
|
||||||
|
if [[ "${plugin}" == "${ref}" || -z "${task}" ]]; then
|
||||||
|
failures+=("gate '${id}' must use plugin@task for gradle-plugin-task")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! grep -RqsE -- "(id|apply plugin:)[[:space:]]+['\"]${plugin}['\"]" "${REPO_ROOT}/src" \
|
||||||
|
--include='build.gradle'; 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_body "${workflow_file}" "${job}" | grep -Eqs -- '\./gradlew[[:space:]]+check([[:space:]]|$)'; 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_body "${workflow_file}" "${job}" | grep -Fqs -- "${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'
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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: 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 --no-daemon --stacktrace
|
||||||
|
|
||||||
|
sample-off:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
- 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
|
||||||
|
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 }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for result in "${QUALITY_RESULT}" "${SAMPLE_OFF_RESULT}" "${MATRIX_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,185 @@
|
|||||||
|
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
|
||||||
|
- 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,43 @@
|
|||||||
|
name: link-check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "README.md"
|
||||||
|
- "src/README.md"
|
||||||
|
- "docs/**/*.md"
|
||||||
|
- ".github/**/*.md"
|
||||||
|
- ".github/workflows/link-check.yml"
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
paths:
|
||||||
|
- "README.md"
|
||||||
|
- "src/README.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
|
||||||
|
'docs/**/*.md'
|
||||||
|
'.github/**/*.md'
|
||||||
|
fail: true
|
||||||
@@ -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: []
|
||||||
@@ -12,7 +12,19 @@
|
|||||||
|
|
||||||
동작하는 코드라도 HARD-STOP 조건을 하나라도 위반하면 완료된 작업이 아니다.
|
동작하는 코드라도 HARD-STOP 조건을 하나라도 위반하면 완료된 작업이 아니다.
|
||||||
|
|
||||||
HARD-STOP 8개 항목의 SSOT 는 `.agents/plugins/ca-superpowers/rules/clean-architecture.md` §HARD-STOP 이다. 이 문서는 목록 사본을 유지하지 않는다 — 불일치 시 SSOT 파일이 우선한다. (항상 로드되는 요약 사본은 root `CLAUDE.md` Prime Directive 에 있다.)
|
다음 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 Workflow
|
||||||
|
|
||||||
@@ -35,17 +47,19 @@ HARD-STOP 8개 항목의 SSOT 는 `.agents/plugins/ca-superpowers/rules/clean-ar
|
|||||||
- `superpowers:using-git-worktrees`: 격리된 작업 공간이 필요할 때 사용한다.
|
- `superpowers:using-git-worktrees`: 격리된 작업 공간이 필요할 때 사용한다.
|
||||||
- `superpowers:writing-skills`: 스킬을 만들거나 수정할 때 사용한다.
|
- `superpowers:writing-skills`: 스킬을 만들거나 수정할 때 사용한다.
|
||||||
|
|
||||||
## Harness 정책 SSOT
|
## Gradle 정책 권위
|
||||||
|
|
||||||
- `.harness/manifest.yaml`: Java 21 / Spring Boot 4.0.0 프로젝트 identity와 task packet 진입점
|
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
|
||||||
- `.harness/project/modules.yaml`: 19개 leaf의 ID, 소스 경로, Gradle path, 허용 edge, focused command
|
Gradle path, 허용 production project dependency edge
|
||||||
- `.harness/core/risk-policy.yaml`: change-surface 기반 risk 분류
|
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
|
||||||
- `.harness/core/evidence-policy.yaml`: risk별 evidence/review profile
|
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
|
||||||
- `.harness/core/review-policy.yaml`: orchestration, option/counterargument, human-only commit 정책
|
architecture-wide verification task
|
||||||
- `.harness/core/report-policy.yaml`: concise/durable report와 citation self-grep 정책
|
|
||||||
|
작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md`를
|
||||||
|
함께 읽는다. focused test는 registry의 `gradle_path`에서
|
||||||
|
`./gradlew <gradle-path>:test --console=plain` 형태로 파생한다. 파일 수만으로 위험을 판단하지 않고,
|
||||||
|
변경한 경계와 런타임·보안·데이터 영향에 맞춰 설계·리뷰·검증 강도를 높인다.
|
||||||
|
|
||||||
작업 시작 시 task packet을 한 번 resolve하고 stable packet/rule hash를 재사용한다. overlay나 관련
|
|
||||||
hash가 바뀔 때만 다시 resolve하거나 rule 전문을 재정독한다. 파일 수는 risk 분류 기준이 아니다.
|
|
||||||
commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit/amend/push하지 않는다.
|
commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit/amend/push하지 않는다.
|
||||||
|
|
||||||
## LLM Wiki 캡처 워크플로우
|
## LLM Wiki 캡처 워크플로우
|
||||||
@@ -58,7 +72,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
|
|||||||
/home/donghyeon/workspace/ai-tool/llm-wiki-private/
|
/home/donghyeon/workspace/ai-tool/llm-wiki-private/
|
||||||
```
|
```
|
||||||
|
|
||||||
에이전트는 해당 vault의 `AGENTS.md`, `CLAUDE.md`, `rules/`, `.agents/`, `.claude/`, `.codex/` 지침을 확인한 뒤 작성한다. ca-tmpl 내부의 상세 실행 규칙은 `.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md`를 따른다.
|
에이전트는 해당 vault의 `AGENTS.md`, `CLAUDE.md`, `rules/`, `.agents/`, `.claude/`, `.codex/` 지침을 확인한 뒤 작성한다.
|
||||||
|
|
||||||
필수 순서:
|
필수 순서:
|
||||||
|
|
||||||
@@ -87,9 +101,10 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
|
|||||||
|
|
||||||
## 모듈 책임
|
## 모듈 책임
|
||||||
|
|
||||||
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 의존성, focused test 명령은
|
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성은
|
||||||
`.harness/project/modules.yaml`이 SSOT다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만
|
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
|
||||||
정의한다. 작업 파일에서는 가장 가까운 `src/**/CLAUDE.md`를 함께 읽는다.
|
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
|
||||||
|
`src/**/CLAUDE.md`를 함께 읽는다.
|
||||||
|
|
||||||
- `domain-core`: 순수 도메인 모델, 불변식, 이벤트, port. Spring/JPA/transport/IO 타입 금지.
|
- `domain-core`: 순수 도메인 모델, 불변식, 이벤트, port. Spring/JPA/transport/IO 타입 금지.
|
||||||
- `application-core`: command, use case, application policy, transaction port, application 예외.
|
- `application-core`: command, use case, application policy, transaction port, application 예외.
|
||||||
@@ -116,7 +131,7 @@ runtime modules -> shared-contract
|
|||||||
sample-portfolio -> registered runtime leaves (fixture consumer only)
|
sample-portfolio -> registered runtime leaves (fixture consumer only)
|
||||||
```
|
```
|
||||||
|
|
||||||
개별 edge는 `.harness/project/modules.yaml`의 `allowed_dependencies`가 유일한 목록이다.
|
개별 edge는 `src/config/architecture/modules.json`의 `allowed_dependencies`가 유일한 목록이다.
|
||||||
Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억에서 leaf edge를 추론하지 않는다.
|
Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억에서 leaf edge를 추론하지 않는다.
|
||||||
|
|
||||||
## 기능 개발 프로토콜
|
## 기능 개발 프로토콜
|
||||||
@@ -157,8 +172,7 @@ Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
python3 ../.harness/validators/resolve_task.py <task-overlay.json>
|
./gradlew <owner-gradle-path>:test --console=plain
|
||||||
# resolved packet의 focused_commands를 실행
|
|
||||||
./gradlew test
|
./gradlew test
|
||||||
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle)
|
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle)
|
||||||
./gradlew verifyCleanArchitectureDependencies
|
./gradlew verifyCleanArchitectureDependencies
|
||||||
@@ -166,8 +180,8 @@ python3 ../.harness/validators/resolve_task.py <task-overlay.json>
|
|||||||
./gradlew verifyEnvKeys
|
./gradlew verifyEnvKeys
|
||||||
```
|
```
|
||||||
|
|
||||||
19개 leaf의 정확한 focused test 명령은 `.harness/project/modules.yaml`과 resolved task packet을
|
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
|
||||||
따른다. root 문서에 별도 명령 목록을 복제하지 않는다.
|
명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다.
|
||||||
|
|
||||||
## 설정과 런타임
|
## 설정과 런타임
|
||||||
|
|
||||||
|
|||||||
@@ -4,49 +4,46 @@ Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture templ
|
|||||||
|
|
||||||
## Prime Directive
|
## Prime Directive
|
||||||
|
|
||||||
Preserve architecture before optimizing for speed. The HARD-STOP SSOT is
|
Preserve architecture before optimizing for speed. The following eight HARD-STOP conditions are a
|
||||||
`.agents/plugins/ca-superpowers/rules/clean-architecture.md`; its eight current stop conditions are:
|
synchronized summary of the canonical local policy in `AGENTS.md`:
|
||||||
|
|
||||||
1. `domain-core` gains framework, transport, database, or cloud dependencies.
|
1. `domain-core` gains framework, transport, database, or cloud dependencies.
|
||||||
2. A controller directly uses a repository, Spring Data interface, or persistence entity.
|
2. A controller directly uses a repository, Spring Data interface, or persistence entity.
|
||||||
3. An inbound DTO leaks into `application-core` or `domain-core`.
|
3. An inbound DTO leaks into `application-core` or `domain-core`.
|
||||||
4. Business rules move into mappers, filters, configuration, settings, or controllers.
|
4. Business rules move into mappers, filters, configuration, settings, or controllers.
|
||||||
5. Project dependencies violate `.harness/project/modules.yaml` and Gradle verification.
|
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.
|
6. Completion is claimed without the relevant verification or a named reason it could not run.
|
||||||
7. A corpus conclusion is made without evidence appropriate to the selected evidence profile.
|
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.
|
8. Non-trivial work closes without the required LLM Wiki capture or a reported capture block.
|
||||||
|
|
||||||
If this summary and the SSOT differ, the SSOT wins.
|
If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must be resynchronized.
|
||||||
|
|
||||||
## Harness policy authorities
|
## Gradle policy authorities
|
||||||
|
|
||||||
- Project manifest and stack: `.harness/manifest.yaml`
|
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
|
||||||
- Leaf modules, dependency edges, and focused commands: `.harness/project/modules.yaml`
|
paths, Gradle paths, and allowed production project dependency edges.
|
||||||
- Risk classification: `.harness/core/risk-policy.yaml`
|
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
|
||||||
- Evidence selection: `.harness/core/evidence-policy.yaml`
|
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
|
||||||
- Orchestration and advisory depth: `.harness/core/review-policy.yaml`
|
verification tasks.
|
||||||
- Durable/concise reporting: `.harness/core/report-policy.yaml`
|
|
||||||
- Physical parity check: `.harness/validators/validate_policy_parity.py`
|
|
||||||
|
|
||||||
Resolve a task packet once and reuse its stable task-packet hash and relevant rule hashes. Rerun
|
|
||||||
resolution or reread a full rule only when the task overlay, packet hash, or rule hash changes.
|
|
||||||
|
|
||||||
Commit policy is `human-only`: agents do not stage, commit, amend, or push implementation changes.
|
Commit policy is `human-only`: agents do not stage, commit, amend, or push implementation changes.
|
||||||
|
|
||||||
## Risk-based orchestration
|
## Proportional workflow
|
||||||
|
|
||||||
- Low risk: implement inline or in a focused lane and run the focused check.
|
- Low risk: work in the owning leaf, follow its nearest guidance, and run the focused check.
|
||||||
- Medium risk: use `ca-implementer`; select proportional review based on affected boundaries and
|
- Medium risk: use the relevant Superpowers design, planning, TDD, debugging, and review workflows
|
||||||
evidence needs.
|
in proportion to the affected boundaries.
|
||||||
- High risk: use `ca-implementer`, then the full chain after a human commit:
|
- High risk: make architecture and behavior decisions explicit, use staged architecture/spec/quality
|
||||||
`ca-architect-sentinel` → `ca-spec-reviewer` → `ca-quality-reviewer` → `gradle-runner`.
|
review, and run architecture-wide verification authorized by the task.
|
||||||
|
|
||||||
Risk comes from change surface and task flags, not file count.
|
Risk comes from change surface and runtime, security, data, or public-contract impact, not file
|
||||||
|
count.
|
||||||
|
|
||||||
## Module families
|
## Module families
|
||||||
|
|
||||||
`.harness/project/modules.yaml` owns the complete 19-leaf list. Root guidance summarizes families;
|
`src/config/architecture/modules.json` owns the complete 19-leaf list. Root guidance summarizes
|
||||||
the nearest `src/**/CLAUDE.md` owns local rules.
|
families; the nearest `src/**/CLAUDE.md` owns local rules.
|
||||||
|
|
||||||
| Family | Responsibility | Stable dependency direction |
|
| Family | Responsibility | Stable dependency direction |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -60,7 +57,8 @@ the nearest `src/**/CLAUDE.md` owns local rules.
|
|||||||
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
|
| `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.
|
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
|
||||||
Read it from `.harness/project/modules.yaml` or from the resolved task packet.
|
Read its `gradle_path` and `allowed_dependencies` from
|
||||||
|
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
|
||||||
|
|
||||||
## Layer workflow
|
## Layer workflow
|
||||||
|
|
||||||
@@ -88,7 +86,9 @@ or writable scope, stop and request context rather than expanding silently.
|
|||||||
- identifier: pure deterministic unit tests.
|
- identifier: pure deterministic unit tests.
|
||||||
- bootstrap/settings: binding, validation, wiring, and architecture tests.
|
- bootstrap/settings: binding, validation, wiring, and architecture tests.
|
||||||
|
|
||||||
From `src/`, run the exact focused command emitted in the task packet. Architecture-wide commands:
|
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
|
```bash
|
||||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
@@ -114,6 +114,6 @@ or the regulated profile. Otherwise a concise result is allowed.
|
|||||||
|
|
||||||
## LLM Wiki capture
|
## LLM Wiki capture
|
||||||
|
|
||||||
For non-trivial implementation or workflow changes, follow
|
For non-trivial implementation or workflow changes, use the exact vault path and capture sequence in
|
||||||
`.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md`. If the controller explicitly excludes
|
`AGENTS.md`. If the controller explicitly excludes wiki writes for a dispatched task, report the
|
||||||
wiki writes for a dispatched task, report the handoff instead of writing outside scope.
|
handoff instead of writing outside scope.
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ family 수준의 책임은 다음과 같습니다.
|
|||||||
| `app-bootstrap` | Spring Boot entrypoint와 composition root |
|
| `app-bootstrap` | Spring Boot entrypoint와 composition root |
|
||||||
| `sample-portfolio` | WorkLog 예시 도메인(fixture/reference). production이 의존하지 않음 |
|
| `sample-portfolio` | WorkLog 예시 도메인(fixture/reference). production이 의존하지 않음 |
|
||||||
|
|
||||||
정확한 19개 leaf 목록, 각 leaf의 Gradle path·허용 의존 edge·focused test 명령은 [.harness/project/modules.yaml](.harness/project/modules.yaml)이 SSOT입니다. root 문서나 기억에서 개별 leaf edge를 추론하지 않습니다.
|
정확한 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를 추론하지 않습니다.
|
||||||
|
|
||||||
## 퀵스타트
|
## 퀵스타트
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@ docker compose -f docker-compose.yml -f docker-compose.local.yml down
|
|||||||
find . -type f \( -name '*.java' -o -name '*.gradle' -o -name '*.yml' \) -print0 | xargs -0 sed -i 's/dev.caskeleton/com.yourorg.yourservice/g'
|
find . -type f \( -name '*.java' -o -name '*.gradle' -o -name '*.yml' \) -print0 | xargs -0 sed -i 's/dev.caskeleton/com.yourorg.yourservice/g'
|
||||||
```
|
```
|
||||||
|
|
||||||
레지스트리 `package_roots`와 애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다.
|
애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다.
|
||||||
|
|
||||||
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
|
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
|
||||||
4. 모듈 이름과 경계는 그대로 유지합니다.
|
4. 모듈 이름과 경계는 그대로 유지합니다.
|
||||||
@@ -82,7 +85,8 @@ cd src
|
|||||||
|
|
||||||
## 아키텍처 규칙과 검증
|
## 아키텍처 규칙과 검증
|
||||||
|
|
||||||
애플리케이션이 동작하더라도 아래를 어기면 병합하지 않습니다. 8개 HARD-STOP 조건의 SSOT는 [clean-architecture.md](.agents/plugins/ca-superpowers/rules/clean-architecture.md)입니다.
|
애플리케이션이 동작하더라도 아래를 어기면 병합하지 않습니다. 8개 HARD-STOP 조건의 정본 로컬
|
||||||
|
정책 권위는 [AGENTS.md](AGENTS.md)이며, [CLAUDE.md](CLAUDE.md)는 동기화된 요약입니다.
|
||||||
|
|
||||||
- `domain-core`는 Spring·JPA·Servlet·HTTP·DB·cloud SDK 타입을 import하지 않습니다.
|
- `domain-core`는 Spring·JPA·Servlet·HTTP·DB·cloud SDK 타입을 import하지 않습니다.
|
||||||
- controller는 repository를 직접 호출하거나 persistence entity를 반환하지 않습니다.
|
- controller는 repository를 직접 호출하거나 persistence entity를 반환하지 않습니다.
|
||||||
@@ -90,7 +94,9 @@ cd src
|
|||||||
- 비즈니스 정책은 mapper·filter·config·settings·controller에 두지 않습니다.
|
- 비즈니스 정책은 mapper·filter·config·settings·controller에 두지 않습니다.
|
||||||
- 새 외부 시스템 연동은 domain/application port와 adapter 모듈로 표현합니다.
|
- 새 외부 시스템 연동은 domain/application port와 adapter 모듈로 표현합니다.
|
||||||
|
|
||||||
이 규칙은 두 축으로 자동 강제합니다. ArchUnit `CleanArchitectureTest`가 컴파일된 소스 의존성을, `verifyCleanArchitectureDependencies` 게이트가 Gradle 프로젝트 의존성을 검사하며, 둘 다 registry의 허용 edge를 읽습니다.
|
이 규칙은 두 축으로 자동 강제합니다. ArchUnit `CleanArchitectureTest`가 컴파일된 소스 의존성을,
|
||||||
|
`verifyCleanArchitectureDependencies` 게이트가 JSON registry의 허용 Gradle project edge를
|
||||||
|
검사합니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
@@ -102,7 +108,9 @@ cd src
|
|||||||
## 더 알아보기
|
## 더 알아보기
|
||||||
|
|
||||||
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
|
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
|
||||||
- 모듈 레지스트리(19개 leaf SSOT): [.harness/project/modules.yaml](.harness/project/modules.yaml)
|
- 모듈 레지스트리(19개 leaf SSOT): [src/config/architecture/modules.json](src/config/architecture/modules.json)
|
||||||
- 에이전트·기여자 작업 규칙: [AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md)
|
- 에이전트·기여자 작업 규칙: [AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md)
|
||||||
- 빌드·릴리스 공급망 파이프라인: [build-release-supply-chain.yml](.github/workflows/build-release-supply-chain.yml)
|
- 빌드·릴리스 공급망 파이프라인은 현재 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)
|
- 모듈별 설계 결정: [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)
|
||||||
|
|||||||
@@ -540,6 +540,18 @@ env_keys:
|
|||||||
compatibility_impact: behavior-change
|
compatibility_impact: behavior-change
|
||||||
required_test: outbound-contract:global-call-timeout-bounded
|
required_test: outbound-contract:global-call-timeout-bounded
|
||||||
|
|
||||||
|
- name: APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS
|
||||||
|
type: int
|
||||||
|
default: 128
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: httpclient-production-capability
|
||||||
|
validation: int_range_1_10000
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: outbound-contract:maximum-in-flight-calls-bounded
|
||||||
|
|
||||||
- name: APP_OUTBOUND_HTTP_RETRY_ENABLED
|
- name: APP_OUTBOUND_HTTP_RETRY_ENABLED
|
||||||
# source: feature-outbound-http-client-baseline 2026-05-22
|
# source: feature-outbound-http-client-baseline 2026-05-22
|
||||||
# "retry 기본값은 disabled이며, 활성화 시 retryable registry error와 low-cardinality retry metric이 필수"
|
# "retry 기본값은 disabled이며, 활성화 시 retryable registry error와 low-cardinality retry metric이 필수"
|
||||||
@@ -1230,6 +1242,18 @@ env_keys:
|
|||||||
compatibility_impact: behavior-change
|
compatibility_impact: behavior-change
|
||||||
required_test: adapter-contract:redis-disabled-default
|
required_test: adapter-contract:redis-disabled-default
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_CLIENT_MODE
|
||||||
|
type: enum
|
||||||
|
default: managed
|
||||||
|
allowed_values: [managed, external]
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: redis-production-capability
|
||||||
|
validation: enum_strict
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: adapter-contract:redis-client-mode-explicit
|
||||||
|
|
||||||
- name: APP_CACHE_REDIS_HOST
|
- name: APP_CACHE_REDIS_HOST
|
||||||
# source: feature-cache-consistency-contract — Redis adapter (활성화 시 endpoint 필요)
|
# source: feature-cache-consistency-contract — Redis adapter (활성화 시 endpoint 필요)
|
||||||
type: string
|
type: string
|
||||||
@@ -1256,6 +1280,102 @@ env_keys:
|
|||||||
compatibility_impact: behavior-change
|
compatibility_impact: behavior-change
|
||||||
required_test: cache-contract:redis-port-bound
|
required_test: cache-contract:redis-port-bound
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_PASSWORD
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
allowed_values: null
|
||||||
|
classification: secret
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: codex-phase-a-ci-recovery
|
||||||
|
validation: must_not_be_local_dev_sentinel_in_prod
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: secrets-contract:redis-password-no-leak
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_KEY_HMAC_SECRET
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
allowed_values: null
|
||||||
|
classification: secret
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: codex-phase-a-ci-recovery
|
||||||
|
validation: base64_min_32_bytes_when_redis_enabled
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: cache-contract:redis-hmac-secret-bounded
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_COMMAND_TIMEOUT
|
||||||
|
type: duration
|
||||||
|
default: 2s
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: codex-phase-a-ci-recovery
|
||||||
|
validation: spring_duration_shorthand_non_zero_le_30s
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: cache-contract:redis-command-timeout-bounded
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS
|
||||||
|
type: int
|
||||||
|
default: 8
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: codex-phase-a-ci-recovery
|
||||||
|
validation: int_1_to_4096
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: cache-contract:redis-command-queue-bounded
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES
|
||||||
|
type: int
|
||||||
|
default: 16777216
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: codex-phase-a-ci-recovery
|
||||||
|
validation: covers_maximum_value_and_le_268435456
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: cache-contract:redis-command-byte-admission-bounded
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT
|
||||||
|
type: string
|
||||||
|
default: local
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: codex-phase-a-ci-recovery
|
||||||
|
validation: lowercase_slug
|
||||||
|
compatibility_impact: behavior-change
|
||||||
|
required_test: cache-contract:redis-namespace-environment-bound
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_SEMANTIC_REGION
|
||||||
|
type: string
|
||||||
|
default: default
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: codex-phase-a-ci-recovery
|
||||||
|
validation: lowercase_slug
|
||||||
|
compatibility_impact: behavior-change
|
||||||
|
required_test: cache-contract:redis-semantic-region-bound
|
||||||
|
|
||||||
|
- name: APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES
|
||||||
|
type: int
|
||||||
|
default: 1048576
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: codex-phase-a-ci-recovery
|
||||||
|
validation: int_1_to_16777216
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: cache-contract:redis-value-size-bounded
|
||||||
|
|
||||||
- name: APP_CACHE_DEFAULT_TTL
|
- name: APP_CACHE_DEFAULT_TTL
|
||||||
# source: feature-cache-consistency-contract 2026-05-22
|
# source: feature-cache-consistency-contract 2026-05-22
|
||||||
# "TTL | explicit per key family | no-cache for sensitive data | immortal cache forbidden"
|
# "TTL | explicit per key family | no-cache for sensitive data | immortal cache forbidden"
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ secrets:
|
|||||||
compatibility_impact: breaking
|
compatibility_impact: breaking
|
||||||
required_test: secrets-contract:redis-password-no-leak
|
required_test: secrets-contract:redis-password-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_PRIVACY_PSEUDONYMIZATION_SALT
|
- name: APP_PRIVACY_PSEUDONYMIZATION_SALT
|
||||||
# source: feature-data-retention-privacy-contract 2026-05-22
|
# source: feature-data-retention-privacy-contract 2026-05-22
|
||||||
# "pseudonymization key = HMAC-SHA-256 with rotating salt. salt rotation interval = 90일.
|
# "pseudonymization key = HMAC-SHA-256 with rotating salt. salt rotation interval = 90일.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# feature-security-operational-baseline D5 — deny-by-default public path snapshot.
|
||||||
|
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
|
||||||
|
# Regenerate after review with: ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange
|
||||||
|
/api/healthcheck
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
> **SUPERSEDED — HISTORICAL PROVENANCE ONLY (2026-07-25):** The user-approved harness-free
|
||||||
|
> Mode B amendment supersedes this plan. Retain the body as historical provenance; it is not
|
||||||
|
> executable instruction.
|
||||||
|
|
||||||
# Harness Policy Engine Implementation Plan
|
# Harness Policy Engine Implementation Plan
|
||||||
|
|
||||||
> **Spec:** `docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md`
|
> **Spec:** `docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md`
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
# Application Outbox Failure Reporting Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
|
||||||
|
> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use
|
||||||
|
> checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make `application-core` framework/logging-free while preserving one safe structured ERROR
|
||||||
|
after each confirmed outbox FAILED/DEAD transition.
|
||||||
|
|
||||||
|
**Architecture:** The application owns a narrow typed reporting port and safe report value.
|
||||||
|
Messaging renders the report through SLF4J, and bootstrap only injects it. Transition state remains
|
||||||
|
authoritative; reporter failures are non-authoritative and contained.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21 records, JUnit Jupiter, AssertJ, Spring Boot 4 configuration, SLF4J 2 fluent
|
||||||
|
logging, Logback capture tests, ArchUnit, Gradle Groovy DSL, dependency locking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Safe Application Report Contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportTest.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java`
|
||||||
|
|
||||||
|
- [ ] Write factory, invariant, and reflection-whitelist tests for the exact eight record components.
|
||||||
|
- [ ] Run `./gradlew :application-core:test --tests '*OutboxRelayFailureReportTest' --console=plain`
|
||||||
|
and record the expected missing-type RED.
|
||||||
|
- [ ] Implement the immutable record, exact invariants, factories, and functional port.
|
||||||
|
- [ ] Re-run the focused value test and record GREEN.
|
||||||
|
|
||||||
|
### Task 2: Relay Reporting Behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java`
|
||||||
|
- Modify: `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java`
|
||||||
|
- Modify direct test constructor sites under
|
||||||
|
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/`
|
||||||
|
|
||||||
|
- [ ] Add recording/throwing reporters and tests for exact FAILED/DEAD reports, all no-report paths,
|
||||||
|
transition failure propagation, and reporter-failure continuation.
|
||||||
|
- [ ] Run the relay test and record constructor/behavior RED.
|
||||||
|
- [ ] Inject the reporter after the publish port, remove SLF4J, report only after successful
|
||||||
|
transition, and contain reporter `RuntimeException`.
|
||||||
|
- [ ] Update test-only direct constructors with explicit lambdas and re-run relay tests GREEN.
|
||||||
|
|
||||||
|
### Task 3: Structured Messaging Adapter and Publish-Adapter Deduplication
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create:
|
||||||
|
`src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java`
|
||||||
|
- Create:
|
||||||
|
`src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java`
|
||||||
|
- Modify:
|
||||||
|
`src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java`
|
||||||
|
- Modify:
|
||||||
|
`src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java`
|
||||||
|
|
||||||
|
- [ ] Write Logback capture tests for exact ERROR count, fixed fields, throwable, retry-only time,
|
||||||
|
unsafe-data absence, internal logging failure containment, and the adapter contract that
|
||||||
|
`report(null)` never throws.
|
||||||
|
- [ ] Run
|
||||||
|
`./gradlew :adapter:outbound:messaging:test --tests '*Slf4jOutboxRelayFailureReportAdapterTest' --console=plain`
|
||||||
|
and record missing-type RED.
|
||||||
|
- [ ] Implement the SLF4J 2 fluent adapter and re-run GREEN.
|
||||||
|
- [ ] Replace outbox publish WARN expectations with no-log and propagation expectations; run RED.
|
||||||
|
- [ ] Remove `FailOpenDependencyLogger` from the outbox adapter and re-run its tests GREEN, leaving
|
||||||
|
`OutboundMessagePublisher` unchanged.
|
||||||
|
|
||||||
|
### Task 4: Unconditional Reporter Wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java`
|
||||||
|
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java`
|
||||||
|
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java`
|
||||||
|
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java`
|
||||||
|
|
||||||
|
- [ ] Add disabled and active context assertions for exactly one structured reporter bean.
|
||||||
|
- [ ] Run `OptionalAdapterBeanGatingTest` and record RED.
|
||||||
|
- [ ] Add the unconditional messaging reporter bean, use `disabled` for blank broker, update outbox
|
||||||
|
publish adapter construction, and inject the port through bootstrap.
|
||||||
|
- [ ] Re-run the gating and outbox configuration tests GREEN.
|
||||||
|
|
||||||
|
### Task 5: Application Dependency Purity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/build.gradle`
|
||||||
|
- Modify: `src/application-core/build.gradle`
|
||||||
|
- Mechanically regenerate only: `src/application-core/gradle.lockfile`
|
||||||
|
|
||||||
|
- [ ] Add `verifyApplicationCoreDependencyPurity`, wire it into `:application-core:check`, and run it
|
||||||
|
against the current starter declaration to record RED.
|
||||||
|
- [ ] Give `application-core` only JUnit Jupiter and AssertJ test dependencies while retaining the
|
||||||
|
shared Boot test dependencies for every other leaf.
|
||||||
|
- [ ] Remove the application Spring Boot starter and re-run the purity task GREEN.
|
||||||
|
- [ ] Run
|
||||||
|
`./gradlew :application-core:resolveAndLockAll --write-locks --console=plain`; confirm no other
|
||||||
|
lockfile changes.
|
||||||
|
- [ ] Run application lock verification, tests, and compile/test runtime dependency reports.
|
||||||
|
|
||||||
|
### Task 6: Non-Vacuous Diagnostic Architecture Rule
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify:
|
||||||
|
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`
|
||||||
|
- Create:
|
||||||
|
`src/app-bootstrap/src/test/java/dev/caskeleton/application/architecture/violations/ApplicationDiagnosticFrameworkViolation.java`
|
||||||
|
- Modify:
|
||||||
|
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java`
|
||||||
|
|
||||||
|
- [ ] Add the violation fixture inside the exact `dev.caskeleton.application..` rule scope and its
|
||||||
|
mutation assertion; run it before the rule to record RED.
|
||||||
|
- [ ] Add `APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK`, scoped exactly to
|
||||||
|
`dev.caskeleton.application..`, for SLF4J, JUL, Logback, Log4j, and Micrometer.
|
||||||
|
- [ ] Run the mutation test and production `CleanArchitectureTest` GREEN.
|
||||||
|
|
||||||
|
### Task 7: Documentation and Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/application-core/CLAUDE.md`
|
||||||
|
- Modify: `src/application-core/README.md`
|
||||||
|
- Modify: `src/adapter/outbound/messaging/CLAUDE.md`
|
||||||
|
- Modify: `src/adapter/outbound/messaging/README.md`
|
||||||
|
- Modify relevant wiring guidance in `src/app-bootstrap/README.md`
|
||||||
|
|
||||||
|
- [ ] Document the framework-free application contract, typed report semantics, messaging ownership,
|
||||||
|
duplicate-log rule, and bootstrap wiring-only role.
|
||||||
|
- [ ] Run focused application, messaging, gating, architecture mutation, production architecture,
|
||||||
|
and available outbox integration tests.
|
||||||
|
- [ ] Run `verifyCleanArchitectureDependencies`, dependency evidence reports, and `check`.
|
||||||
|
- [ ] Run required safety greps, `git diff --check`, and `git status --short`; report any skip or
|
||||||
|
remaining risk.
|
||||||
|
- [ ] Hand the exact LLM Wiki capture responsibility and evidence back to the top-level controller;
|
||||||
|
do not write the vault from this dispatched scope.
|
||||||
|
|
||||||
|
No step authorizes staging, committing, amending, pushing, public-path changes, CI changes, module
|
||||||
|
registry changes, or `.harness` changes.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Harness-Free Mode B Amendment Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Execute this plan task-by-task with
|
||||||
|
> `superpowers:executing-plans`; use `superpowers:test-driven-development` for the build behavior
|
||||||
|
> change and `superpowers:verification-before-completion` before reporting results.
|
||||||
|
|
||||||
|
**Goal:** Restore Gradle bootstrap and Clean Architecture dependency enforcement without recreating
|
||||||
|
the absent development harness.
|
||||||
|
|
||||||
|
**Architecture:** One strict JSON registry under `src/config/architecture/` owns all 19 leaf
|
||||||
|
identities, paths, and allowed production project edges. Gradle settings validate and include the
|
||||||
|
registry fail-closed; the root dependency verification task reads the same file and checks actual
|
||||||
|
production project dependencies against it.
|
||||||
|
|
||||||
|
**Tech Stack:** Gradle Groovy DSL, Groovy `JsonSlurper`, strict JSON, Java 21.
|
||||||
|
|
||||||
|
**Working policy:** Human-only git handling. Do not stage, commit, amend, push, or create a PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Capture the broken bootstrap
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Read: `src/settings.gradle`
|
||||||
|
|
||||||
|
- [x] Run `cd src && ./gradlew help --console=plain`.
|
||||||
|
- [x] Confirm exit 1 is caused by the missing `.harness/project/modules.yaml`, not dependency
|
||||||
|
resolution or an unrelated build failure.
|
||||||
|
|
||||||
|
### Task 2: Add the Gradle-owned registry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/config/architecture/modules.json`
|
||||||
|
- Read: each of the 19 leaf-module `build.gradle` files
|
||||||
|
|
||||||
|
- [x] Record exactly 19 unique module IDs, Gradle paths, and repository-relative source paths.
|
||||||
|
- [x] Set `allowed_dependencies` from each leaf's current `api`, `implementation`, `compileOnly`,
|
||||||
|
and `runtimeOnly` project dependencies.
|
||||||
|
- [x] Exclude test/fixture configurations from production policy and keep `sample-portfolio` a
|
||||||
|
fixture consumer that no production leaf may depend on.
|
||||||
|
- [x] Parse the file with Python's strict JSON parser and compare its edges with the checked-in
|
||||||
|
leaf build declarations.
|
||||||
|
|
||||||
|
### Task 3: Restore Gradle bootstrap and dependency enforcement
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/settings.gradle`
|
||||||
|
- Modify: `src/build.gradle`
|
||||||
|
|
||||||
|
- [x] Make settings load only `config/architecture/modules.json`.
|
||||||
|
- [x] Fail closed on a missing registry, wrong root/module/field types, empty values, duplicate
|
||||||
|
identities or paths, unsafe path shapes, unknown/self dependencies, count drift, or missing
|
||||||
|
source directories.
|
||||||
|
- [x] Include every registered Gradle path and map it to its repository-root-relative source
|
||||||
|
directory.
|
||||||
|
- [x] Make `verifyCleanArchitectureDependencies` read the same registry without a second module
|
||||||
|
list.
|
||||||
|
- [x] Preserve all-leaf coverage and forbidden-edge checks, explicitly reject a production edge
|
||||||
|
to `sample-portfolio`, and replace stale error wording with actionable registry guidance.
|
||||||
|
|
||||||
|
### Task 4: Align active repository guidance
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `AGENTS.md`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `src/README.md`
|
||||||
|
- Modify: all 19 nearest leaf-module `CLAUDE.md` files that name the old registry
|
||||||
|
- Annotate as superseded: the 2026-07-20 harness design and plan
|
||||||
|
|
||||||
|
- [x] Point active topology and allowed-edge guidance to
|
||||||
|
`src/config/architecture/modules.json`.
|
||||||
|
- [x] State that focused commands are derived from the owning Gradle path rather than a task
|
||||||
|
packet.
|
||||||
|
- [x] Keep all eight local HARD-STOP meanings, architecture boundaries, human-only git policy,
|
||||||
|
verification discipline, and LLM Wiki capture requirements.
|
||||||
|
- [x] Make the earlier harness documents explicit historical provenance rather than active
|
||||||
|
reconstruction instructions.
|
||||||
|
|
||||||
|
### Task 5: Verify from a fresh Gradle invocation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Verify: all changed files
|
||||||
|
|
||||||
|
- [ ] Run `cd src && ./gradlew help --console=plain`.
|
||||||
|
- [ ] Run `cd src && ./gradlew projects --console=plain`.
|
||||||
|
- [ ] Run `cd src && ./gradlew verifyCleanArchitectureDependencies --console=plain`.
|
||||||
|
- [ ] Run a deterministic strict-JSON script proving exactly 19 unique IDs/Gradle paths and
|
||||||
|
existing source directories.
|
||||||
|
- [ ] Run a deterministic comparison between registry edges and leaf production project
|
||||||
|
dependencies.
|
||||||
|
- [ ] Run `git diff --check` and `git status --short`.
|
||||||
|
- [ ] Report exact exits, any unavailable checks, LLM Wiki capture outcome, and remaining risks
|
||||||
|
without claiming the broader Phase A/refactor is complete.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Harness-Free Quality and Security CI Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this
|
||||||
|
> plan task-by-task, `superpowers:test-driven-development` for executable drift controls, and
|
||||||
|
> `superpowers:verification-before-completion` before reporting. Git remains human-only: do not
|
||||||
|
> stage, commit, amend, or push.
|
||||||
|
|
||||||
|
**Goal:** Reconstruct a harness-free, repository-internal quality and dependency-security CI
|
||||||
|
control plane that is truthful to the current Gradle build and `main` branch.
|
||||||
|
|
||||||
|
**Architecture:** Canonical workflows live only under `.github/workflows`. A small YAML gate matrix
|
||||||
|
maps current controls to real Gradle tasks/plugins/tests and workflow jobs, while a portable Bash
|
||||||
|
verifier rejects drift; vulnerability policy is enforced by a platform-neutral Trivy filesystem
|
||||||
|
job with guarded GitHub-only complements.
|
||||||
|
|
||||||
|
**Tech Stack:** GitHub Actions-compatible YAML, Bash, Gradle 9 Groovy DSL, Java/Temurin 21, Trivy,
|
||||||
|
jq, lychee.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Capture missing-control RED
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Verify absent: `.trivyignore.yaml`
|
||||||
|
- Verify absent: `.github/ci-gate-matrix.yml`
|
||||||
|
- Verify absent: `.github/scripts/verify-gate-matrix.sh`
|
||||||
|
|
||||||
|
- [ ] Run `cd src && ./gradlew verifyTrivyignore --console=plain`.
|
||||||
|
- [ ] Confirm the failure names the missing repository-root `.trivyignore.yaml`.
|
||||||
|
- [ ] Confirm the matrix, verifier, and canonical workflows are absent.
|
||||||
|
|
||||||
|
### Task 2: Add repository baselines
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `.tool-versions`
|
||||||
|
- Create: `.gitattributes`
|
||||||
|
- Create: `.trivyignore.yaml`
|
||||||
|
|
||||||
|
- [ ] Pin `java temurin-21.0.11+10`, matching candidate evidence and the local Gradle launcher JDK.
|
||||||
|
- [ ] Normalize source, YAML, Markdown, Gradle, and shell text to LF; keep `gradlew.bat` CRLF and
|
||||||
|
mark common binary formats `-text`.
|
||||||
|
- [ ] Add the four structured empty Trivy sections with suppression governance comments.
|
||||||
|
- [ ] Run `cd src && ./gradlew verifyTrivyignore --console=plain` and expect zero suppressions
|
||||||
|
validated.
|
||||||
|
|
||||||
|
### Task 3: Add quality governance and drift verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `.github/CODEOWNERS`
|
||||||
|
- Create: `.github/pull_request_template.md`
|
||||||
|
- Create: `.github/ci-gate-matrix.yml`
|
||||||
|
- Create: `.github/scripts/verify-gate-matrix.sh`
|
||||||
|
- Create: `.github/workflows/ci-quality-gates.yml`
|
||||||
|
- Create: `.github/workflows/link-check.yml`
|
||||||
|
|
||||||
|
- [ ] Record only current Gradle/task/test/job mechanisms in the matrix.
|
||||||
|
- [ ] Implement repository-root-safe matrix parsing with schema, uniqueness, task/plugin/test, and
|
||||||
|
workflow-job checks.
|
||||||
|
- [x] Before Java/Gradle, fail unless `docs/security/public-paths-snapshot.txt` is committed and
|
||||||
|
non-empty; do not let the Gradle task create a first-run CI baseline.
|
||||||
|
- [ ] Have a human track and commit the canonical snapshot; agents do not stage or commit, and CI's
|
||||||
|
`git ls-files` precondition rejects an untracked worktree file.
|
||||||
|
- [ ] Add required `quality-gates`, `sample-off`, and `gate-matrix-lint` jobs plus the advisory
|
||||||
|
quarantine job.
|
||||||
|
- [ ] Make `release-gate` depend exactly on the three required jobs and fail unless all succeeded.
|
||||||
|
- [ ] Add path-scoped link checking for PR and `main` push.
|
||||||
|
- [ ] Pin every workflow `uses:` reference to a verified full commit SHA and retain its immutable
|
||||||
|
release label in an inline comment.
|
||||||
|
- [ ] Run Bash syntax and gate-matrix checks.
|
||||||
|
|
||||||
|
### Task 4: Add dependency-vulnerability controls
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `.github/dependency-review-config.yml`
|
||||||
|
- Create: `.github/dependency-vulnerability-policy.md`
|
||||||
|
- Create: `.github/scripts/install-jq.sh`
|
||||||
|
- Create: `.github/workflows/dependency-vulnerability.yml`
|
||||||
|
|
||||||
|
- [ ] Configure PR dependency review to block new High/Critical runtime vulnerabilities and
|
||||||
|
forbidden strong/network-copyleft licenses without posting PR summary comments.
|
||||||
|
- [ ] Document High/Critical blocking, Medium/Low advisory, KEV fail-closed handling, suppression
|
||||||
|
review, GitHub/Gitea differences, egress, and mirror requirements.
|
||||||
|
- [ ] Install checksum-pinned jq and version-pinned Trivy under `${RUNNER_TEMP}`, adding them through
|
||||||
|
`${GITHUB_PATH}` without privileged writes.
|
||||||
|
- [ ] Guard GitHub-only review/submission and keep `trivy-fs` platform-neutral on all required
|
||||||
|
triggers.
|
||||||
|
- [ ] Pass `--ignorefile .trivyignore.yaml` to every Trivy invocation.
|
||||||
|
- [ ] Reject KEV catalogs with blank metadata, non-positive/non-integral or mismatched counts,
|
||||||
|
empty vulnerability arrays, invalid CVE identifiers, or duplicate identifiers before
|
||||||
|
intersection.
|
||||||
|
- [ ] Reject malformed or empty Trivy JSON before extracting candidate vulnerability identifiers.
|
||||||
|
|
||||||
|
### Task 5: Verify the reconstructed slice
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Verify: all files created by this plan
|
||||||
|
|
||||||
|
- [ ] Parse strict policy/matrix YAML with an available parser and document GitHub `on` parser
|
||||||
|
limitations if applicable.
|
||||||
|
- [ ] Prove only `main` is an active branch trigger and no active `master` remains.
|
||||||
|
- [ ] Prove every Trivy scan consumes the root ignore file.
|
||||||
|
- [ ] Prove the release fan-in is exact and excludes quarantine.
|
||||||
|
- [x] Prove the missing/empty/untracked snapshot precondition exits non-zero; the canonical
|
||||||
|
`/api/healthcheck` snapshot now exists in the worktree but still requires a human commit.
|
||||||
|
- [ ] Exercise the KEV predicate with empty/malformed/count/CVE/duplicate failures and a valid
|
||||||
|
synthetic catalog.
|
||||||
|
- [ ] Exercise the Trivy JSON predicate with malformed Results/Vulnerabilities/IDs and a realistic
|
||||||
|
valid Results array.
|
||||||
|
- [ ] Prove no harness call or `.gitea/workflows` shadow was introduced.
|
||||||
|
- [ ] Run `git diff --check` and `git status --short`.
|
||||||
|
- [ ] Capture the work in the required LLM Wiki branch note, including evidence and external
|
||||||
|
blockers, without claiming server Actions or full Phase A completion.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Harness-Free Module and Gradle Hygiene Implementation Plan
|
||||||
|
|
||||||
|
**Goal:** Apply the approved 19-leaf dependency and boundary cleanup without `.harness`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-25-module-gradle-hygiene-harness-free-design.md`
|
||||||
|
|
||||||
|
**Policy:** TDD for behavior/boundary changes; focused proof before dependency removal; human-only
|
||||||
|
Git operations.
|
||||||
|
|
||||||
|
## Task 1: Lock Phase B and characterize the Phase C baseline
|
||||||
|
|
||||||
|
- [ ] Confirm the Phase B focused tests, dependency-purity gate, spec review, and quality review
|
||||||
|
are green.
|
||||||
|
- [ ] Record the current 19-leaf registry and affected lockfiles.
|
||||||
|
- [ ] Run the existing OpenAPI runtime tests before changing springdoc.
|
||||||
|
|
||||||
|
## Task 2: Isolate pure-core tests
|
||||||
|
|
||||||
|
- [ ] Change the root test convention so `domain-core`, `application-core`, and
|
||||||
|
`shared-contract` receive only JUnit Jupiter, AssertJ, and the platform launcher.
|
||||||
|
- [ ] Run the three core test suites and dependency reports.
|
||||||
|
- [ ] Regenerate only their affected locks and prove no Spring coordinate remains on their test
|
||||||
|
runtime classpaths.
|
||||||
|
|
||||||
|
## Task 3: Prune core/inbound declarations and align Boot 4
|
||||||
|
|
||||||
|
- [ ] Before editing, run and record each affected leaf's `compileJava`, `compileTestJava`, `test`,
|
||||||
|
runtime dependency report, and relevant dependency insight.
|
||||||
|
- [ ] Remove the approved unused project edges from application and inbound leaves.
|
||||||
|
- [ ] Upgrade springdoc to `3.0.0`.
|
||||||
|
- [ ] Remove unused GraphQL/WebSocket Jackson 2 declarations and unused gRPC direct declarations.
|
||||||
|
- [ ] Characterize `jackson-databind-nullable` with dependency insight and focused
|
||||||
|
present/null/undefined Jackson 3 tests; exclude its Jackson 2 transitive dependency only if the
|
||||||
|
tests and real-server OpenAPI contract remain green.
|
||||||
|
- [ ] Run each affected leaf test plus the two real-server `/v3/api-docs` tests.
|
||||||
|
- [ ] Update the OpenAPI snapshot only if the generated public contract is semantically unchanged.
|
||||||
|
|
||||||
|
## Task 4: Prune outbound declarations
|
||||||
|
|
||||||
|
- [ ] Before editing, run and record each affected leaf's `compileJava`, `compileTestJava`, `test`,
|
||||||
|
runtime dependency report, and relevant dependency insight.
|
||||||
|
- [ ] Apply the approved support/cache/httpclient/identifier/messaging/notification project-edge
|
||||||
|
removals.
|
||||||
|
- [ ] Remove Groovy/Spock only from leaves with no Groovy tests.
|
||||||
|
- [ ] Narrow fileserver/objectstorage from the broad Boot starter to autoconfigure plus SLF4J API.
|
||||||
|
- [ ] Remove the JPA domain edge and remove explicit Flyway core only if focused compile/test proves
|
||||||
|
it is redundant.
|
||||||
|
- [ ] Run affected compile/tests before and after each dependency group.
|
||||||
|
|
||||||
|
## Task 5: Enforce configuration-processor parity
|
||||||
|
|
||||||
|
- [ ] Add a failing verification fixture or temporary mutation proving the exact
|
||||||
|
`@ConfigurationProperties(` parity check detects missing and extra processors.
|
||||||
|
- [ ] Register `verifyConfigurationPropertiesProcessor` from the JSON registry and wire it into
|
||||||
|
leaf `check`.
|
||||||
|
- [ ] Add processors to settings-owning leaves and remove the unused GraphQL processor.
|
||||||
|
- [ ] Run the new gate and affected settings tests.
|
||||||
|
|
||||||
|
## Task 6: Remove the Mongo example domain
|
||||||
|
|
||||||
|
- [ ] Add tests for disabled mode, enable-flag binding, and enabled infrastructure with a mock
|
||||||
|
`MongoClient`.
|
||||||
|
- [ ] Delete all production/test `Example*` types and remove the fixed example bean/repository
|
||||||
|
scanning.
|
||||||
|
- [ ] Remove obsolete project and Testcontainers dependencies.
|
||||||
|
- [ ] Run the Mongo tests and an `rg` assertion that production contains no `Example*`.
|
||||||
|
|
||||||
|
## Task 7: Invert sample correlation access
|
||||||
|
|
||||||
|
- [ ] Add framework-free `CorrelationIdPort` contract tests/fakes.
|
||||||
|
- [ ] Add and test the inbound web MDC implementation.
|
||||||
|
- [ ] Change the two sample application collaborators to use the port while retaining event-id
|
||||||
|
fallback behavior.
|
||||||
|
- [ ] Add an architecture assertion that sample application source has no SLF4J dependency.
|
||||||
|
- [ ] Run application, web, sample outbox/poster, and architecture focused tests.
|
||||||
|
|
||||||
|
## Task 8: Clean generated state and composition documentation
|
||||||
|
|
||||||
|
- [ ] Delete tracked `src/sample-portfolio/.jqwik-database` and ignore future files.
|
||||||
|
- [ ] Correct app-bootstrap “every module” wording and document default versus opt-in runtime
|
||||||
|
composition.
|
||||||
|
- [ ] Preserve the existing default runtime dependency set.
|
||||||
|
|
||||||
|
## Task 9: Locks, full verification, and review
|
||||||
|
|
||||||
|
- [ ] Regenerate strict lockfiles only with each affected leaf's
|
||||||
|
`:leaf-path:resolveAndLockAll --write-locks`; do not run the root all-leaf writer.
|
||||||
|
- [ ] Run all commands in the design verification section.
|
||||||
|
- [ ] Run `git diff --check` and inspect the complete unstaged/untracked status.
|
||||||
|
- [ ] Request spec and code-quality review; fix all actionable findings.
|
||||||
|
- [ ] Update the mandated LLM Wiki raw branch note and derived raw notes, or record the exact
|
||||||
|
missing-vault blocker.
|
||||||
|
|
||||||
|
## Final review hardening
|
||||||
|
|
||||||
|
- [x] Pin the Springdoc 3 `ApiError.details` widening with a real-server RED test.
|
||||||
|
- [x] Add a web-owned OpenAPI customizer, import it in both real-server test applications, and
|
||||||
|
restore the committed `type: object` snapshot without adding Swagger to `shared-contract`.
|
||||||
|
- [x] Reproduce starter-driven Mongo activation through an actual `@EnableAutoConfiguration`
|
||||||
|
context in both default and explicit-false modes.
|
||||||
|
- [x] Register a module-level Boot 4 `AutoConfigurationImportFilter` that blocks Mongo
|
||||||
|
auto-configuration until the module enable flag is true.
|
||||||
|
- [x] Re-run affected formatting, locks, focused tests, and all design verification commands.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Fileserver Durable Recovery Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this
|
||||||
|
> plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Repository policy is
|
||||||
|
> human-only, so no step stages or commits changes.
|
||||||
|
|
||||||
|
**Goal:** Make the local publication provider restart-safe for completed and sealed operations
|
||||||
|
without re-running the row producer.
|
||||||
|
|
||||||
|
**Architecture:** Keep the application port unchanged. The adapter owns a private operation journal
|
||||||
|
under `.ca-fileserver/operations`, writes records through forced temp files and atomic rename, and
|
||||||
|
uses a deterministic request fingerprint. A retry restores a verified terminal receipt or resumes a
|
||||||
|
sealed staged artifact; disagreement is a conflict or indeterminate outcome, never an overwrite.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21 NIO, JUnit 5, AssertJ, existing Gradle quality gates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Define deterministic journal records and request fingerprints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalRecord.java`
|
||||||
|
- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java`
|
||||||
|
- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java`
|
||||||
|
- Test: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalTest.java`
|
||||||
|
|
||||||
|
- [x] Write a failing test proving stable request fingerprints and different fingerprints for
|
||||||
|
source/schema changes.
|
||||||
|
- [x] Write a failing test proving journal round-trip and rejection of corrupt/newer records.
|
||||||
|
- [x] Run
|
||||||
|
`./gradlew :adapter:outbound:fileserver:test --tests '*LocalPublicationJournalTest' --console=plain`
|
||||||
|
and confirm the missing types fail compilation.
|
||||||
|
- [x] Implement a bounded flat JSON codec with schema version, state, fingerprint, locator token,
|
||||||
|
checksum/counts and receipt snapshot fields. It must reject duplicate/unknown keys and never
|
||||||
|
serialize absolute paths or row data.
|
||||||
|
- [x] Run the focused test and confirm GREEN.
|
||||||
|
|
||||||
|
### Task 2: Add forced atomic journal persistence and recovery
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournal.java`
|
||||||
|
- Modify: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java`
|
||||||
|
- Test: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationRecoveryTest.java`
|
||||||
|
|
||||||
|
- [x] Write a failing test where a completed operation is retried with a producer that throws; the
|
||||||
|
original receipt must be returned and the producer must remain uncalled.
|
||||||
|
- [x] Write a failing test that reconstructs a new adapter over a sealed journal plus staged bytes
|
||||||
|
and resumes publication without calling the producer.
|
||||||
|
- [x] Write a failing test proving the same operation ID with a different request is a conflict and
|
||||||
|
a digest mismatch is indeterminate.
|
||||||
|
- [x] Run the recovery test and confirm RED.
|
||||||
|
- [x] Persist `WRITING`, `SEALED`, and `PUBLISHED` records with temp + force + atomic move. Verify
|
||||||
|
the target size and SHA-256 before terminal reconstruction.
|
||||||
|
- [x] Run all Fileserver tests and confirm GREEN.
|
||||||
|
|
||||||
|
### Task 3: Report the exact readiness boundary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/fileserver/README.md`
|
||||||
|
- Modify: `src/adapter/outbound/fileserver/CLAUDE.md`
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md`
|
||||||
|
|
||||||
|
- [x] Mark single-node local restart recovery as implemented.
|
||||||
|
- [x] Keep multi-node fencing, bounded background reaper, SFTP, NFS and HA evidence explicitly
|
||||||
|
unimplemented.
|
||||||
|
- [x] Run `./gradlew :adapter:outbound:fileserver:check --console=plain`.
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Fileserver Production Capability Foundation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the list-materializing CSV demo boundary with the Phase 1 framework-free publication contract and a bounded, staged local CSV R1 provider without claiming crash-safe R2 guarantees.
|
||||||
|
|
||||||
|
**Architecture:** `application-core` owns typed publication requests, rows, cells, producer/sink callbacks, opaque references, and receipts. `adapter:outbound:fileserver` owns CSV encoding, spreadsheet-formula mitigation, staging, digest/count limits, and local atomic publication. The legacy `FileExportPort` remains temporarily for compatibility and is explicitly documented as deprecated R0/R1 behavior.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, JUnit 5, AssertJ, Spring Boot configuration properties, JDK NIO filesystem and SHA-256.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add the framework-free publication contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationPort.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishRequest.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishOperationId.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileDestinationId.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/LogicalFileName.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/SourceRevision.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/ExportSchema.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularCell.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRow.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowProducer.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowSink.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/PublishedFileReference.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileVersion.java`
|
||||||
|
- Test: `src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing contract test**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void requestRejectsPathLikeLogicalNamesAndSchemaRejectsDuplicateColumns() {
|
||||||
|
assertThatThrownBy(() -> new LogicalFileName("../report.csv"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
new ExportSchema(
|
||||||
|
"worklog-v1",
|
||||||
|
1,
|
||||||
|
List.of(
|
||||||
|
new ExportSchema.Column(
|
||||||
|
"id", ExportSchema.CellType.INTEGER, false,
|
||||||
|
ExportSchema.FormulaPolicy.REJECT, 64),
|
||||||
|
new ExportSchema.Column(
|
||||||
|
"id", ExportSchema.CellType.TEXT, false,
|
||||||
|
ExportSchema.FormulaPolicy.MITIGATE, 128))))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify RED**
|
||||||
|
|
||||||
|
Run: `cd src && ./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain`
|
||||||
|
|
||||||
|
Expected: compilation failure because the `filepublication` contract does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement immutable validated values**
|
||||||
|
|
||||||
|
The contract must expose this shape and no `Path`, `File`, stream, Spring, or provider type:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface FilePublicationPort {
|
||||||
|
FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface TabularRowProducer {
|
||||||
|
void produce(TabularRowSink sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface TabularRowSink {
|
||||||
|
void write(TabularRow row);
|
||||||
|
void checkpoint();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`TabularCell` is a sealed interface with nested records for text, integer, decimal, boolean, date,
|
||||||
|
instant, and null. `ExportSchema` owns ordered columns, cell type, nullability, formula policy, and
|
||||||
|
per-cell byte bounds. Records reject null/blank IDs, path separators in `LogicalFileName`, duplicate
|
||||||
|
column names, empty schemas, and non-positive limits.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify GREEN**
|
||||||
|
|
||||||
|
Run: `cd src && ./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 2: Add streaming CSV encoding and staged local publication
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java`
|
||||||
|
- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java`
|
||||||
|
- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationPolicy.java`
|
||||||
|
- Test: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapterTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing streaming publication tests**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void publishesRowsThroughTheSinkAndReturnsAnOpaqueReceipt() {
|
||||||
|
AtomicInteger calls = new AtomicInteger();
|
||||||
|
FilePublishReceipt receipt =
|
||||||
|
adapter.publish(
|
||||||
|
request(),
|
||||||
|
sink -> {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("=cmd"))));
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(calls).hasValue(1);
|
||||||
|
assertThat(receipt.reference().value()).doesNotContain(tempDir.toString());
|
||||||
|
assertThat(Files.readString(publishedFile(receipt), UTF_8)).contains("1,'=cmd");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void abortsBeforeFinalPublicationWhenTheByteLimitIsExceeded() {
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> adapter.publish(request(), sink -> sink.write(oversizedRow())))
|
||||||
|
.isInstanceOf(FilePublicationException.class);
|
||||||
|
assertThat(finalArtifacts()).isEmpty();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify RED**
|
||||||
|
|
||||||
|
Run: `cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*LocalFilePublicationAdapterTest' --console=plain`
|
||||||
|
|
||||||
|
Expected: compilation failure because the staged provider does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the minimum staged provider**
|
||||||
|
|
||||||
|
`LocalFilePublicationPolicy` validates a fixed destination ID, base directory, maximum rows,
|
||||||
|
maximum encoded bytes, and the only initial format profile `csv-rfc4180-v1`.
|
||||||
|
|
||||||
|
`LocalFilePublicationAdapter` must:
|
||||||
|
|
||||||
|
```text
|
||||||
|
validate request/schema before producer invocation
|
||||||
|
create a private .staging directory
|
||||||
|
exclusive-create an operation-scoped .part file
|
||||||
|
write header and each row directly through StreamingCsvEncoder
|
||||||
|
enforce schema/cell/row/byte limits at each sink call
|
||||||
|
prefix dangerous spreadsheet text with a single quote when policy is MITIGATE
|
||||||
|
compute SHA-256 and counts while writing
|
||||||
|
flush and FileChannel.force(true)
|
||||||
|
move staging to the final operation-scoped file with ATOMIC_MOVE
|
||||||
|
delete staging on pre-publish failure
|
||||||
|
return an opaque reference and never an absolute path
|
||||||
|
```
|
||||||
|
|
||||||
|
The first release is labelled local R1. Existing final artifacts cause a typed conflict; durable
|
||||||
|
operation journals, crash reconciliation, replace semantics, and SFTP/NFS remain unimplemented and
|
||||||
|
must not be advertised.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify GREEN**
|
||||||
|
|
||||||
|
Run: `cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*LocalFilePublicationAdapterTest' --console=plain`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Add opt-in R1 composition and truthful documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java`
|
||||||
|
- Modify: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java`
|
||||||
|
- Create: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java`
|
||||||
|
- Modify: `src/adapter/outbound/fileserver/README.md`
|
||||||
|
- Modify: `src/adapter/outbound/fileserver/CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing composition test**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void disabledConfigurationCreatesNoPublicationPort() {
|
||||||
|
contextRunner
|
||||||
|
.withUserConfiguration(FileExportConfig.class)
|
||||||
|
.run(context -> assertThat(context).doesNotHaveBean(FilePublicationPort.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enabledConfigurationCreatesExactlyOneLocalR1PublicationPort() {
|
||||||
|
contextRunner
|
||||||
|
.withUserConfiguration(FileExportConfig.class)
|
||||||
|
.withPropertyValues(
|
||||||
|
"ca-skeleton.fileserver.enabled=true",
|
||||||
|
"ca-skeleton.fileserver.destination-id=local-export",
|
||||||
|
"ca-skeleton.fileserver.base-directory=" + tempDir)
|
||||||
|
.run(context -> assertThat(context).hasSingleBean(FilePublicationPort.class));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify RED**
|
||||||
|
|
||||||
|
Run: `cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*FilePublicationConfigTest' --console=plain`
|
||||||
|
|
||||||
|
Expected: FAIL because the new port is not composed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire only the local R1 provider**
|
||||||
|
|
||||||
|
Add validated destination ID, row limit, byte limit, and format-profile settings. Contribute
|
||||||
|
`FilePublicationPort` only when explicitly enabled. Keep `FileExportPort` as a deprecated compatibility
|
||||||
|
bean and document that it materializes caller rows and is not R2 evidence.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify module and architecture gates**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :application-core:test :adapter:outbound:fileserver:check --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all commands PASS.
|
||||||
|
|
||||||
|
### Task 4: Record the unfinished R2 boundary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update implementation status without weakening completion criteria**
|
||||||
|
|
||||||
|
Record Phase 0–1/local R1 foundation as implemented. Keep Phase 2 durable journal/reconciliation,
|
||||||
|
Phase 3 operations, Phase 4 SFTP, Phase 5 NFS/HA/bootstrap, and Phase 6 optional operations marked
|
||||||
|
unimplemented. The document must still say that local R1 is not Fileserver R2.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify documentation structure**
|
||||||
|
|
||||||
|
Run: `rg -n 'R1|R2|구현 상태|미구현' docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md`
|
||||||
|
|
||||||
|
Expected: explicit R1 implementation and remaining R2 gaps are both present.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# HTTP Client Production Capability Foundation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
|
||||||
|
> (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Establish the framework-free call-budget and typed operation/target foundation, then close
|
||||||
|
two proven safety defects in the legacy JDK provider without claiming Apache HC5, hard total
|
||||||
|
deadline, egress security, or R2 readiness.
|
||||||
|
|
||||||
|
**Architecture:** `application-core` owns only a monotonic `CallBudget`. Product forks continue to
|
||||||
|
own feature-specific semantic ports. `adapter:outbound:httpclient` owns destination/operation IDs,
|
||||||
|
immutable operation descriptors, relative target construction, status/retry/body semantics, and
|
||||||
|
legacy provider fixes. The generic `OutboundHttpClient` remains a migration facade.
|
||||||
|
|
||||||
|
**Scope boundary:** This applies Phase 0 and a bounded Phase 1 foundation. Canonical binding
|
||||||
|
composition, exact readiness tuple registry, Apache HC5 pool, active cancellation, TLS/DNS/proxy,
|
||||||
|
auth, codec, and real-network qualification remain unimplemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add a framework-free monotonic call budget
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java`
|
||||||
|
- Test: `src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java`
|
||||||
|
|
||||||
|
- [ ] Write RED tests for expiry, remaining time, finite bounds, and parent/child intersection.
|
||||||
|
- [ ] Implement without Spring, wall-clock timestamps, scheduler, or HTTP types.
|
||||||
|
- [ ] Verify GREEN.
|
||||||
|
|
||||||
|
### Task 2: Add typed operation catalog and safe target construction
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/build.gradle`
|
||||||
|
- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpDestinationId.java`
|
||||||
|
- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationId.java`
|
||||||
|
- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationDescriptor.java`
|
||||||
|
- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalog.java`
|
||||||
|
- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/target/FixedHttpDestination.java`
|
||||||
|
- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/target/HttpTargetBuilder.java`
|
||||||
|
- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalogTest.java`
|
||||||
|
- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/target/HttpTargetBuilderTest.java`
|
||||||
|
|
||||||
|
- [ ] Write RED tests for ID/uniqueness/cross-field operation invariants.
|
||||||
|
- [ ] Write RED tests rejecting absolute, scheme-relative, traversal, user-info, query/fragment, and
|
||||||
|
multi-segment variables.
|
||||||
|
- [ ] Implement closed immutable descriptors and one-pass path-segment encoding.
|
||||||
|
- [ ] Verify GREEN.
|
||||||
|
|
||||||
|
### Task 3: Correct characterized legacy provider safety defects
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java`
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java`
|
||||||
|
- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientSafetyRegressionTest.java`
|
||||||
|
|
||||||
|
- [ ] Reproduce streaming 5xx body delivery and logical-call-only circuit-breaker counting.
|
||||||
|
- [ ] Make streaming validate status before exposing the body and discard error bodies.
|
||||||
|
- [ ] Put circuit breaker around each physical attempt and retry around the attempt loop.
|
||||||
|
- [ ] Set JDK redirects to `NEVER` explicitly and validate legacy base URI/relative request targets.
|
||||||
|
- [ ] Verify focused regressions and the full legacy test suite.
|
||||||
|
|
||||||
|
### Task 4: Record exact readiness and verify
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/README.md`
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/CLAUDE.md`
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md`
|
||||||
|
|
||||||
|
- [ ] Mark the implemented foundation and fixed legacy defects.
|
||||||
|
- [ ] Keep total deadline/cancellation, canonical zero-binding composition, Apache pool, fixed
|
||||||
|
egress, TLS/auth, bounded decoded streaming, and R2 cards unimplemented.
|
||||||
|
- [ ] Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :application-core:check :adapter:outbound:httpclient:check --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
```
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# HTTP Client Total Deadline Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this
|
||||||
|
> plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Repository policy is
|
||||||
|
> human-only, so no step stages or commits changes.
|
||||||
|
|
||||||
|
**Goal:** Enforce `CallBudget` across the legacy HTTP logical call, including retry wait and blocking
|
||||||
|
I/O, and cancel the executing task when the absolute monotonic deadline wins.
|
||||||
|
|
||||||
|
**Architecture:** Preserve the current migration facade but inject a bounded executor owned by each
|
||||||
|
client. Every call intersects the caller budget with the configured maximum, passes the same
|
||||||
|
absolute deadline to retry policy, waits through `Future.get(remaining)`, and cancels on timeout or
|
||||||
|
shutdown. This is R1 cancellation evidence, not Apache pool or hard-wire-cancellation R2 evidence.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21 virtual-thread executor, Spring RestClient/JDK HttpClient, Resilience4j,
|
||||||
|
JUnit loopback HTTP server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add deadline execution and explicit timeout vocabulary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallDeadlineExceededException.java`
|
||||||
|
- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallExecutor.java`
|
||||||
|
- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallExecutorTest.java`
|
||||||
|
|
||||||
|
- [x] Write failing tests proving an expired budget does not start work, a running task is
|
||||||
|
interrupted on expiry, and completion wins before the deadline.
|
||||||
|
- [x] Confirm RED.
|
||||||
|
- [x] Implement absolute monotonic remaining-time calculation, `Future.get`, cancellation and
|
||||||
|
exact exception mapping.
|
||||||
|
- [x] Confirm GREEN.
|
||||||
|
|
||||||
|
### Task 2: Connect the budget to buffered and streaming calls
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java`
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java`
|
||||||
|
- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientDeadlineTest.java`
|
||||||
|
|
||||||
|
- [x] Write a failing loopback test where response delay exceeds the budget and confirm bounded
|
||||||
|
return; record that JDK-provider server-side hard close is not proven by this lane.
|
||||||
|
- [x] Write a failing test proving a shorter caller budget wins and retry cannot start after expiry.
|
||||||
|
- [x] Confirm RED.
|
||||||
|
- [x] Add overloads accepting `CallBudget`; existing methods create a configured maximum budget.
|
||||||
|
Intersect budgets once and use the same deadline for retry and blocking execution.
|
||||||
|
- [x] Confirm GREEN and run the complete HTTP leaf tests.
|
||||||
|
|
||||||
|
### Task 3: Record provider limits and verify
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/README.md`
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/CLAUDE.md`
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md`
|
||||||
|
|
||||||
|
- [x] Record active logical-call deadline/cancellation as implemented.
|
||||||
|
- [x] Keep explicit pool lease, Apache exact provider, DNS rebinding, TLS/auth/proxy and R2 hard
|
||||||
|
cancellation evidence unimplemented.
|
||||||
|
- [x] Run the HTTP leaf check and architecture/public-path gates.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Redis Production Capability Foundation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
|
||||||
|
> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use
|
||||||
|
> checkbox syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the adapter-only cache seam with a framework-free semantic cache contract, safe
|
||||||
|
physical key construction, and a versioned typed atomic-program foundation without claiming that a
|
||||||
|
real Redis runtime or any R2 capability is complete.
|
||||||
|
|
||||||
|
**Architecture:** `application-core` owns provider-neutral cache outcomes and mutation intent.
|
||||||
|
`adapter:outbound:cache-redis` owns physical key construction, digesting, Lua resources, program
|
||||||
|
descriptors, and typed primitive facades. Existing legacy routing remains compatible while migration
|
||||||
|
is incremental. No Redis SDK, raw command, raw key, or Lua concept crosses into core.
|
||||||
|
|
||||||
|
**Scope boundary:** This batch implements Phase 0 and selected Phase 1 foundations. Spring Data
|
||||||
|
Redis/Lettuce runtime, codec/envelope, real-service integration, topology, distributed rate limit,
|
||||||
|
idempotency, lease, session, and R2/R3 evidence remain separate implementation phases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add the provider-neutral cache contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordIntent.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordOutcome.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheInvalidationOutcome.java`
|
||||||
|
- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/AuthoritativeAbsence.java`
|
||||||
|
- Test: `src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java`
|
||||||
|
|
||||||
|
- [ ] Write a failing test for hit/negative/miss/unavailable distinctions and immutable metadata.
|
||||||
|
- [ ] Verify RED with `./gradlew :application-core:test --tests '*CacheRegionContractTest'`.
|
||||||
|
- [ ] Implement only framework-free values and ports.
|
||||||
|
- [ ] Verify GREEN.
|
||||||
|
|
||||||
|
### Task 2: Add canonical Redis physical keys
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/build.gradle`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyNamespace.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilder.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyDigest.java`
|
||||||
|
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilderTest.java`
|
||||||
|
|
||||||
|
- [ ] Write a failing test proving namespace isolation, one stable hash tag, bounded key bytes, and
|
||||||
|
absence of raw sensitive resource identifiers.
|
||||||
|
- [ ] Verify RED.
|
||||||
|
- [ ] Implement SHA-256 for opaque IDs and HMAC-SHA-256 for sensitive scopes using defensive secret
|
||||||
|
copies and length-prefixed component encoding.
|
||||||
|
- [ ] Verify GREEN.
|
||||||
|
|
||||||
|
### Task 3: Add a typed, versioned atomic-program catalog
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramId.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramDescriptor.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramCatalog.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramExecutor.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisAtomicPrimitives.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-delete-v1.lua`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-expire-v1.lua`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/set-if-absent-with-ttl-v1.lua`
|
||||||
|
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramCatalogTest.java`
|
||||||
|
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisAtomicPrimitivesTest.java`
|
||||||
|
|
||||||
|
- [ ] Write failing catalog and facade tests.
|
||||||
|
- [ ] Verify RED.
|
||||||
|
- [ ] Implement exact resource digest, key/argument bounds, typed status mapping, and no generic
|
||||||
|
application-facing execution surface.
|
||||||
|
- [ ] Verify GREEN.
|
||||||
|
|
||||||
|
### Task 4: Record exact readiness and verify
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/README.md`
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/CLAUDE.md`
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`
|
||||||
|
|
||||||
|
- [ ] Mark only contract/key/program foundation as implemented and all real runtime/capability
|
||||||
|
promotion as unimplemented.
|
||||||
|
- [ ] Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Do not claim Redis cache R1/R2 until a real standalone service lane and codec/runtime evidence
|
||||||
|
exist.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Redis Runtime And Semantic Cache Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this
|
||||||
|
> plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Repository policy is
|
||||||
|
> human-only, so no step stages or commits changes.
|
||||||
|
|
||||||
|
**Goal:** Replace the SDK-less Redis seam with an opt-in managed Lettuce runtime, a real Lua
|
||||||
|
executor, and a bounded semantic string-cache implementation.
|
||||||
|
|
||||||
|
**Architecture:** A package-private runtime owns `RedisClient`, connection and synchronous binary
|
||||||
|
commands. The Lua executor uses the compiled catalog checksum and `EVALSHA`, falling back to `EVAL`
|
||||||
|
only for `NOSCRIPT`. A versioned binary envelope distinguishes positive, negative and incompatible
|
||||||
|
entries behind `CacheRegionPort<String,String>`.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Lettuce Core managed by Spring Boot 4 BOM, Spring Boot configuration
|
||||||
|
properties, JUnit 5, optional Docker-backed Redis qualification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add the managed runtime and typed program execution
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/build.gradle`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettings.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntime.java`
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeTest.java`
|
||||||
|
|
||||||
|
- [x] Write failing tests for URI/timeout validation, lifecycle close, binary get/set/delete and
|
||||||
|
`EVALSHA -> NOSCRIPT -> EVAL`.
|
||||||
|
- [x] Confirm RED before adding the Lettuce production dependency.
|
||||||
|
- [x] Add `io.lettuce:lettuce-core` using the Boot BOM and update the affected dependency locks.
|
||||||
|
- [x] Implement a package-private runtime with finite command/shutdown timeouts, bounded reconnect
|
||||||
|
behavior, and no connection side effects while disabled or in external-client mode.
|
||||||
|
- [x] Verify focused tests GREEN.
|
||||||
|
|
||||||
|
### Task 2: Implement the semantic cache region
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionPolicy.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheEnvelopeCodec.java`
|
||||||
|
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegion.java`
|
||||||
|
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegionTest.java`
|
||||||
|
|
||||||
|
- [x] Write failing tests for hit, negative hit, miss, incompatible schema, positive/negative TTL,
|
||||||
|
invalidation and provider failure certainty.
|
||||||
|
- [x] Confirm RED.
|
||||||
|
- [x] Implement a bounded versioned binary envelope and HMAC-derived physical keys. Support UPSERT;
|
||||||
|
return `NOT_RECORDED_PROVIDER_POLICY` for opaque revision ordering the provider cannot prove.
|
||||||
|
- [x] Confirm GREEN and run the complete Redis leaf test suite.
|
||||||
|
|
||||||
|
### Task 3: Qualify and document without false promotion
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/README.md`
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/CLAUDE.md`
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`
|
||||||
|
- Modify: runtime configuration and env-key registry only for settings actually introduced.
|
||||||
|
|
||||||
|
- [x] If a local Redis image is available, run an explicit real-service program/cache test; never
|
||||||
|
silently skip it.
|
||||||
|
- [x] Mark standalone runtime/cache as R1 unless real service, restart, ACL/TLS and fault evidence
|
||||||
|
required by the readiness card all pass.
|
||||||
|
- [x] Run the leaf check, dependency lock check, env-key gate and architecture gate.
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
> **SUPERSEDED — HISTORICAL PROVENANCE ONLY (2026-07-25):** The user-approved harness-free
|
||||||
|
> Mode B amendment supersedes this design. Retain the body as historical provenance; it is not
|
||||||
|
> executable instruction.
|
||||||
|
|
||||||
# Harness Policy Engine Refactoring Design
|
# Harness Policy Engine Refactoring Design
|
||||||
|
|
||||||
- **Date:** 2026-07-20
|
- **Date:** 2026-07-20
|
||||||
|
|||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
# Application Outbox Failure Reporting — Harness-Free Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`application-core` currently carries Spring Boot and SLF4J only because
|
||||||
|
`PublishPendingOutboxEventsUseCase` renders relay failures itself. That reverses the diagnostic
|
||||||
|
dependency direction and also permits a duplicate WARN in `OutboxMessagePublishAdapter`.
|
||||||
|
|
||||||
|
This change is harness-free: `src/config/architecture/modules.json`, Gradle, ArchUnit, and focused
|
||||||
|
module tests are the policy and evidence authorities. No `.harness` files or public paths change.
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
`application-core` owns a specific `OutboxRelayFailureReportPort` and an immutable
|
||||||
|
`OutboxRelayFailureReport`. The report is an allowlist containing only:
|
||||||
|
|
||||||
|
- `OperationalError code`
|
||||||
|
- event, aggregate, and correlation identifiers
|
||||||
|
- event type, attempt count, optional next-attempt time
|
||||||
|
- the originating `RuntimeException`
|
||||||
|
|
||||||
|
It never carries the payload, idempotency key, message template, severity, arbitrary fields, or the
|
||||||
|
whole `OutboxEvent`. Factories and record invariants admit only retryable
|
||||||
|
`OUTBOX_PUBLISH_FAILED` reports with a next-attempt time and terminal `OUTBOX_DEAD_LETTER` reports
|
||||||
|
without one.
|
||||||
|
|
||||||
|
`adapter:outbound:messaging` owns `Slf4jOutboxRelayFailureReportAdapter`. It maps the typed report to
|
||||||
|
one canonical SLF4J 2 fluent ERROR with fixed key names and runbook links. Bootstrap only wires the
|
||||||
|
port.
|
||||||
|
|
||||||
|
## Ordering and Failure Semantics
|
||||||
|
|
||||||
|
The persisted FAILED or DEAD transition is authoritative:
|
||||||
|
|
||||||
|
1. broker publication fails;
|
||||||
|
2. the application calculates the transition;
|
||||||
|
3. the store transition succeeds inside `TransactionPort`;
|
||||||
|
4. only then is the typed report emitted.
|
||||||
|
|
||||||
|
A transition failure propagates and emits no report. A reporter `RuntimeException` is contained by
|
||||||
|
both the adapter and the use case, so it cannot change the relay outcome or prevent later events
|
||||||
|
from running. Successful publication and `markPublished` failures emit no failure report.
|
||||||
|
|
||||||
|
There is no production no-op reporter. `MessagingConfig` always contributes exactly one reporter
|
||||||
|
bean, using the configured broker name or `disabled` when blank. `OutboxMessagePublishAdapter`
|
||||||
|
becomes mapping/send-only: runtime failures propagate, checked failures are wrapped with their
|
||||||
|
cause, and it emits no success or failure log. The general `OutboundMessagePublisher` retains its
|
||||||
|
existing fail-open dependency logging.
|
||||||
|
|
||||||
|
## Structured ERROR Contract
|
||||||
|
|
||||||
|
Every confirmed transition produces one ERROR with the common fields:
|
||||||
|
|
||||||
|
`error.code`, `error.category`, `dependency_name`, `dependency_type=messaging`, `outcome`,
|
||||||
|
`event_id`, `event_type`, `aggregate_id`, `correlation_id`, `attempt_count`, and `runbook_link`.
|
||||||
|
|
||||||
|
Retryable failures additionally carry `next_attempt_at`. Mappings are:
|
||||||
|
|
||||||
|
| Code | Outcome | Runbook |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OUTBOX_PUBLISH_FAILED` | `FAILED` | `runbook://outbox/publish-failed` |
|
||||||
|
| `OUTBOX_DEAD_LETTER` | `DEAD` | `runbook://outbox/dead-letter` |
|
||||||
|
|
||||||
|
The originating exception is attached as the throwable. Payload, idempotency key, envelope data,
|
||||||
|
message templates derived from the exception, and arbitrary exception fields are forbidden.
|
||||||
|
The adapter's fail-open boundary also applies to invalid direct calls: `report(null)` must never
|
||||||
|
throw. The focused structured-adapter test pins this behavior.
|
||||||
|
|
||||||
|
## Enforcement and Tests
|
||||||
|
|
||||||
|
- Value tests enforce invariants and reflectively pin the exact record component allowlist.
|
||||||
|
- Relay tests pin transition-before-report ordering, no-report paths, exact cardinality, and
|
||||||
|
reporter containment.
|
||||||
|
- Messaging tests capture Logback events and pin level, fields, throwable, and unsafe-data absence.
|
||||||
|
- `verifyApplicationCoreDependencyPurity` rejects non-project production declarations and forbidden
|
||||||
|
Spring/logging/metrics groups on resolved application classpaths.
|
||||||
|
- `APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK` bans SLF4J, JUL, Logback, Log4j, and Micrometer from
|
||||||
|
the exact `dev.caskeleton.application..` scope. Its dedicated violation fixture also resides
|
||||||
|
inside that scope, under `dev.caskeleton.application.architecture.violations`, proving the rule
|
||||||
|
is non-vacuous.
|
||||||
|
- `application-core` test dependencies are reduced to JUnit Jupiter and AssertJ; all other leaves
|
||||||
|
keep the shared Spring Boot test baseline.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
No public path, CI workflow, module-registry edge, payload shape, outbox persistence schema, or
|
||||||
|
general publisher logging behavior changes. Agents do not stage, commit, amend, or push.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Harness-Free Mode B Amendment
|
||||||
|
|
||||||
|
- **Date:** 2026-07-25
|
||||||
|
- **Status:** Approved scope amendment
|
||||||
|
- **Mode:** B — controlled reconstruction from repository evidence
|
||||||
|
- **Supersedes:** `2026-07-20-harness-policy-engine-design.md` and
|
||||||
|
`2026-07-20-harness-policy-engine.md` in full as executable guidance; both superseded documents
|
||||||
|
remain only as historical provenance
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The repository will recover Gradle configuration and Clean Architecture dependency enforcement
|
||||||
|
without reconstructing the absent development harness. A Gradle-owned JSON registry at
|
||||||
|
`src/config/architecture/modules.json` becomes the single source of truth for the current 19 leaf
|
||||||
|
modules, their repository-relative source paths, Gradle paths, and allowed production project
|
||||||
|
dependencies.
|
||||||
|
|
||||||
|
Both `src/settings.gradle` and `verifyCleanArchitectureDependencies` consume that file. Settings
|
||||||
|
validation fails closed for malformed, empty, duplicate, unsafe, or missing module entries. The
|
||||||
|
dependency gate continues to require complete leaf coverage and reject unapproved production
|
||||||
|
project edges; production leaves may never depend on the `sample-portfolio` fixture consumer.
|
||||||
|
|
||||||
|
## Evidence and provenance
|
||||||
|
|
||||||
|
Registry entries are reconstructed from the checked-in Gradle topology and each leaf
|
||||||
|
`build.gradle`'s `api`, `implementation`, `compileOnly`, and `runtimeOnly` project dependencies.
|
||||||
|
Test-only and fixture-only configurations are not architecture production edges. This is Mode B
|
||||||
|
provenance: it restores the repository's observable build contract, not unavailable historical
|
||||||
|
artifacts.
|
||||||
|
|
||||||
|
The pre-change RED command is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew help --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
It fails because `src/settings.gradle` requires the absent
|
||||||
|
`.harness/project/modules.yaml`.
|
||||||
|
|
||||||
|
## Explicit non-goals
|
||||||
|
|
||||||
|
- No `.harness/` tree, task resolver, task packet, or policy-hash runtime.
|
||||||
|
- No `.agents/`, `.claude/`, `.codex/`, agent plugin, hook, renderer, or platform parity
|
||||||
|
reconstruction.
|
||||||
|
- No production Java or runtime behavior change.
|
||||||
|
- No byte-identical restoration claim.
|
||||||
|
- No claim that the earlier Harness Policy Engine plan or the broader refactor is complete.
|
||||||
|
|
||||||
|
## Enforcement and workflow
|
||||||
|
|
||||||
|
Gradle and CI gates replace harness runtime dependencies for module discovery and dependency
|
||||||
|
policy. Root and module guidance point to the Gradle-owned registry and retain the eight local
|
||||||
|
HARD-STOP meanings, architecture responsibilities, focused-test discipline, human-only git
|
||||||
|
policy, and LLM Wiki capture workflow.
|
||||||
|
|
||||||
|
Acceptance requires successful Gradle `help`, `projects`, and
|
||||||
|
`verifyCleanArchitectureDependencies`, an independent deterministic 19-leaf registry check,
|
||||||
|
`git diff --check`, and a reviewed working-tree status.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Harness-Free Quality and Security CI Design
|
||||||
|
|
||||||
|
- **Date:** 2026-07-25
|
||||||
|
- **Status:** Approved Mode B reconstruction
|
||||||
|
- **Scope:** Repository-internal quality, dependency-vulnerability, and link-check controls
|
||||||
|
|
||||||
|
## Decision and provenance
|
||||||
|
|
||||||
|
Mode B reconstructs observable CI contracts from the current Gradle build, active documentation,
|
||||||
|
and the incomplete `/home/donghyeon/dev/ca-tmpl` checkout. The candidate checkout is evidence, not
|
||||||
|
an authoritative or byte-identical restoration source. Its useful policy is adapted to the current
|
||||||
|
`main` branch and current tasks; stale `master`, feature-branch ownership, and absent workflow
|
||||||
|
claims are removed.
|
||||||
|
|
||||||
|
`.github/workflows/` is the canonical workflow path. No `.gitea/workflows` shadow is created. The
|
||||||
|
origin is Gitea, but server-side Actions is externally disabled, so these files define repository
|
||||||
|
controls without claiming that remote jobs currently execute.
|
||||||
|
|
||||||
|
Every external `uses:` reference is pinned to a verified 40-character commit SHA. Its immutable
|
||||||
|
release tag remains beside the SHA as an inline review label; moving major-version tags are not an
|
||||||
|
execution authority.
|
||||||
|
|
||||||
|
## Scope boundary
|
||||||
|
|
||||||
|
This slice owns:
|
||||||
|
|
||||||
|
- pinned Java tool evidence and text/binary normalization;
|
||||||
|
- structured Trivy suppression governance and CODEOWNERS review surfaces;
|
||||||
|
- the quality-gate matrix and its drift verifier;
|
||||||
|
- quality, filesystem vulnerability, and documentation-link workflows;
|
||||||
|
- human-readable dependency severity, suppression, network, and forge-compatibility policy.
|
||||||
|
|
||||||
|
The development harness remains excluded: no `.harness`, `.agents`, `.claude`, or `.codex`
|
||||||
|
runtime is reconstructed. Build/release supply-chain, tag release, image scanning, signing,
|
||||||
|
provenance, SBOM, retention, and Docker root-context work belongs to the later Phase A2 slice and
|
||||||
|
is not represented as a present workflow job.
|
||||||
|
|
||||||
|
## Considered approaches
|
||||||
|
|
||||||
|
1. Copy the candidate files unchanged. Rejected because they target `master`, refer to missing
|
||||||
|
supply-chain scripts/jobs, and describe obsolete branch ownership.
|
||||||
|
2. Reconstruct a minimal current control plane from repository evidence. Selected because every
|
||||||
|
gate can be checked against a present Gradle task, test, script, or workflow job.
|
||||||
|
3. Merge all checks into one workflow. Rejected because GitHub-only dependency APIs need forge
|
||||||
|
guards, scheduled vulnerability scans have different triggers, and link checks are path-scoped.
|
||||||
|
|
||||||
|
## Components and gate flow
|
||||||
|
|
||||||
|
`ci-quality-gates.yml` runs three required jobs: the aggregate Gradle quality suite, the sample-off
|
||||||
|
axis, and gate-matrix lint. Before Java setup or Gradle, the quality job requires
|
||||||
|
`docs/security/public-paths-snapshot.txt` to be committed and non-empty. The worktree now contains
|
||||||
|
the canonical baseline for `/api/healthcheck`; because agents do not stage or commit, a human must
|
||||||
|
track and commit it before CI's `git ls-files` precondition can pass. This prevents
|
||||||
|
`verifyPublicPathSnapshot` from creating a first-run baseline inside CI and passing without
|
||||||
|
comparison.
|
||||||
|
|
||||||
|
`release-gate` uses `if: always()` and accepts only `success` from those three jobs; the advisory
|
||||||
|
quarantine job is deliberately outside its `needs`.
|
||||||
|
|
||||||
|
The quality aggregate runs `check`, `verifyPublicPathSnapshot`, and `verifyDependencyLocks`
|
||||||
|
explicitly. `check` already pulls in Clean Architecture dependency enforcement, environment/readme
|
||||||
|
drift checks, Trivy-ignore governance, format/static analysis, normal tests, and quarantine sunset.
|
||||||
|
|
||||||
|
`dependency-vulnerability.yml` keeps GitHub Dependency Graph operations behind
|
||||||
|
`github.server_url == 'https://github.com'`. Platform-neutral `trivy-fs` runs for PR, `main` push,
|
||||||
|
daily schedule, and manual dispatch. Trivy and jq install into `${RUNNER_TEMP}` and expose their
|
||||||
|
directories through `${GITHUB_PATH}`. Every Trivy scan names `.trivyignore.yaml`; High/Critical and
|
||||||
|
KEV matches block, while Medium/Low only report. The KEV gate first rejects blank metadata,
|
||||||
|
non-positive/non-integral or mismatched counts, empty arrays, invalid CVE identifiers, and duplicate
|
||||||
|
identifiers. It separately rejects malformed/empty Trivy JSON before extracting candidate IDs.
|
||||||
|
Dependency review reports through its check only and does not request permission to write a PR
|
||||||
|
summary comment. Vulnerability DB, tool release, malformed/empty KEV or Trivy data, and KEV feed
|
||||||
|
network failures remain blocking unless internal mirrors are configured.
|
||||||
|
|
||||||
|
`link-check.yml` is path-scoped for PR and `main` push, and remains manually runnable.
|
||||||
|
|
||||||
|
## Drift verification and failure behavior
|
||||||
|
|
||||||
|
`.github/ci-gate-matrix.yml` lists only current mechanisms/jobs. The verifier resolves the
|
||||||
|
repository root from its own physical location, rejects incomplete/duplicate records, and checks
|
||||||
|
referenced Gradle custom tasks, plugins, contract-test files, workflow files, and job IDs.
|
||||||
|
Delegated-pending is supported only when a row is explicitly marked; no absent supply-chain job is
|
||||||
|
invented in this slice.
|
||||||
|
|
||||||
|
The CI release fan-in fails for failed, cancelled, or unexpectedly skipped required jobs. Trivy's
|
||||||
|
KEV feed cross-check is fail-closed. GitHub-only jobs may skip by their explicit forge/event
|
||||||
|
conditions and are not dependencies of the quality release fan-in.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Acceptance requires the prescribed RED for the absent `.trivyignore.yaml`, GREEN
|
||||||
|
`verifyTrivyignore`, proof that the snapshot precondition rejects missing, empty, or untracked
|
||||||
|
baselines, and a human-tracked canonical snapshot for CI. It also requires strict synthetic KEV
|
||||||
|
catalog negative/positive cases, shell syntax and matrix verification, workflow YAML/static checks,
|
||||||
|
evidence that `main` is the only active branch trigger, Trivy ignorefile coverage, exact release
|
||||||
|
fan-in, absence of harness/Gitea shadow workflows, `git diff --check`, and reviewed working-tree
|
||||||
|
status. Network Trivy scans are intentionally not run locally.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Harness-Free Module and Gradle Hygiene Design
|
||||||
|
|
||||||
|
- **Date:** 2026-07-25
|
||||||
|
- **Status:** Approved
|
||||||
|
- **Mode:** B reconstruction without `.harness`
|
||||||
|
- **Scope:** all 19 Gradle leaves, dependency declarations, test baselines, Mongo scaffolding,
|
||||||
|
runtime-composition documentation, and dependency locks
|
||||||
|
- **Topology SSOT:** `src/config/architecture/modules.json`
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
The 19-leaf project dependency graph obeys the registered allowed edges, and the three core
|
||||||
|
production source sets are free of Spring, persistence, transport, logging, and metrics imports.
|
||||||
|
The audit nevertheless found a wider declared graph than the source graph, Spring WebMVC test
|
||||||
|
libraries on pure-core test classpaths, Boot 3-era OpenAPI tooling on Spring Boot 4, example-domain
|
||||||
|
code in the production Mongo adapter, and direct MDC access in sample application services.
|
||||||
|
|
||||||
|
This design follows the user-approved Mode B reconstruction. It does not recreate or depend on
|
||||||
|
`.harness`; settings and verification continue to consume the JSON registry.
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. Keep the exact 19 leaves and all allowed project edges in the JSON registry.
|
||||||
|
2. Remove only dependencies proven unnecessary by source/test inspection plus focused
|
||||||
|
compile/test verification.
|
||||||
|
3. Give `domain-core`, `application-core`, and `shared-contract` JUnit/AssertJ-only test
|
||||||
|
classpaths.
|
||||||
|
4. Keep Spring Boot 4.0.0 and replace `springdoc-openapi` 2.x with the Boot 4-compatible 3.0.0
|
||||||
|
line.
|
||||||
|
5. Remove unused direct Jackson 2 declarations from GraphQL and WebSocket.
|
||||||
|
6. Require the Spring configuration processor exactly in leaves whose main source declares
|
||||||
|
`@ConfigurationProperties`.
|
||||||
|
7. Remove adapter-local `Example*` business concepts from `persistence-mongo`; retain only
|
||||||
|
opt-in Mongo infrastructure and typed enablement settings.
|
||||||
|
8. Replace sample application-layer MDC reads with an application-owned correlation-context port
|
||||||
|
implemented by the inbound web adapter.
|
||||||
|
9. Remove tracked jqwik runtime state and ignore future `.jqwik-database` files.
|
||||||
|
10. Describe the default bootstrap as the default runtime composition, not as wiring every
|
||||||
|
optional leaf.
|
||||||
|
11. Regenerate only affected strict dependency locks and finish with the full release gates.
|
||||||
|
|
||||||
|
## 3. Non-goals
|
||||||
|
|
||||||
|
- No endpoint, persistence schema, public response, outbox transition, or sample-domain behavior
|
||||||
|
change.
|
||||||
|
- No version catalog, convention-plugin, `buildSrc`, module rename, or registry schema expansion.
|
||||||
|
- No automatic addition of GraphQL, gRPC, WebSocket, Mongo, file server, or object storage to the
|
||||||
|
default `app-bootstrap` runtime.
|
||||||
|
- No stage, commit, amend, or push.
|
||||||
|
|
||||||
|
## 4. Approved dependency decisions
|
||||||
|
|
||||||
|
An allowed registry edge is permission, not an obligation to declare it.
|
||||||
|
|
||||||
|
| Leaf | Remove after focused proof | Preserve |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `application-core` | unused `domain-core` edge | `shared-contract` |
|
||||||
|
| `inbound:web` | unused `domain-core` edge | application/shared and transport dependencies |
|
||||||
|
| `inbound:graphql` | application/domain edges, direct Jackson 2, unused processor | shared and GraphQL/web test transport |
|
||||||
|
| `inbound:grpc` | application/domain edges, unused annotations/direct protobuf declarations | shared, netty, services, configuration processor |
|
||||||
|
| `inbound:websocket` | application/shared edges, direct Jackson 2 | domain, WebSocket, configuration processor |
|
||||||
|
| `outbound:support` | domain/application/shared edges | autoconfigure and SLF4J API |
|
||||||
|
| `outbound:cache-redis` | domain/application, unused Groovy/Spock | shared/support |
|
||||||
|
| `outbound:httpclient` | domain/application | shared/support, actual Groovy/Spock tests |
|
||||||
|
| `outbound:identifier` | domain, `uuid-creator` | application, actual Groovy/Spock tests |
|
||||||
|
| `outbound:messaging` | domain, unused Groovy/Spock | application/shared/support/SLF4J |
|
||||||
|
| `outbound:notification` | domain, unused Groovy/Spock | application/shared/support/web/SLF4J |
|
||||||
|
| `outbound:persistence-jpa` | domain; explicit Flyway core only if focused compile proves the starter sufficient | application/shared/JPA/PostgreSQL |
|
||||||
|
| `outbound:persistence-mongo` | application/shared, `Example*`, example Testcontainers tests | Mongo opt-in infrastructure/settings |
|
||||||
|
| `outbound:fileserver` | broad Boot starter | application/shared, autoconfigure, SLF4J |
|
||||||
|
| `outbound:objectstorage` | broad Boot starter | application/shared/AWS, autoconfigure, SLF4J, vendor IT |
|
||||||
|
|
||||||
|
Production composition-root dependencies remain even when bootstrap source does not statically
|
||||||
|
import their types: their purpose is runtime assembly. Duplicate test declarations may be removed
|
||||||
|
only when the focused test classpath continues to compile and execute.
|
||||||
|
|
||||||
|
## 5. Pure-core test and verification policy
|
||||||
|
|
||||||
|
`domain-core`, `application-core`, and `shared-contract` receive only JUnit Jupiter, AssertJ, and
|
||||||
|
the JUnit launcher from the root convention. All other leaves keep the existing Spring test
|
||||||
|
baseline in this change; family-wide convention plugins are out of scope.
|
||||||
|
|
||||||
|
The existing application dependency-purity gate remains. A new registry-driven configuration
|
||||||
|
processor parity gate applies this Boolean invariant to every leaf and is wired into `check`:
|
||||||
|
main source contains one or more exact `@ConfigurationProperties(` occurrences if and only if the
|
||||||
|
leaf `build.gradle` contains exactly one Spring configuration-processor declaration. It must ignore
|
||||||
|
`@ConfigurationPropertiesScan`; the number of settings classes is not compared with the number of
|
||||||
|
processor declarations.
|
||||||
|
|
||||||
|
## 6. Spring Boot 4 compatibility
|
||||||
|
|
||||||
|
The web adapter changes
|
||||||
|
`org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6` to `3.0.0`, the first stable
|
||||||
|
springdoc line released for Spring Boot 4.0.0. The existing sample tests that boot a real server
|
||||||
|
and call `/v3/api-docs` are the behavior gate. Snapshot changes are accepted only if they are a
|
||||||
|
deterministic library-version result and retain the public API contract.
|
||||||
|
|
||||||
|
Springdoc 3 otherwise widens `ApiError.details` from the committed `type: object` to an
|
||||||
|
unconstrained OAS 3.1 schema. A web-adapter-owned `OpenApiCustomizer` must restore the object schema
|
||||||
|
in the final generated document. Both real-server test applications import that production
|
||||||
|
configuration. `shared-contract` remains free of Swagger annotations and dependencies.
|
||||||
|
|
||||||
|
GraphQL and WebSocket remove direct `com.fasterxml.jackson` declarations because neither source
|
||||||
|
set imports them and Spring Boot 4 owns its JSON stack through the relevant starters.
|
||||||
|
The web adapter retains the `JsonNullable` value type, but its `0.2.6` artifact also declares
|
||||||
|
Jackson 2 transitively while this repository supplies explicit Jackson 3 serializers. Before and
|
||||||
|
after dependency insight plus focused present/null/undefined serialization tests determine whether
|
||||||
|
that transitive edge can be excluded. Exclusion is applied only if those tests and the real-server
|
||||||
|
OpenAPI tests pass; springdoc/Swagger's independently required JSON graph is not removed by
|
||||||
|
assumption.
|
||||||
|
|
||||||
|
## 7. Mongo production boundary
|
||||||
|
|
||||||
|
Delete the adapter-local `ExampleRecord`, document, mapper, repository, repository adapter, and
|
||||||
|
their tests. `MongoPersistenceConfig` remains conditional on
|
||||||
|
`ca-skeleton.persistence-mongo.enabled=true` and explicitly imports the Mongo client/data
|
||||||
|
auto-configurations without owning a fake business repository.
|
||||||
|
|
||||||
|
The starter also registers Mongo auto-configuration directly through Boot metadata, independently
|
||||||
|
of `MongoPersistenceConfig`. A module-level `AutoConfigurationImportFilter`, registered through
|
||||||
|
Boot 4's `META-INF/spring.factories` discovery path, must exclude the Boot 4 sync/reactive client,
|
||||||
|
data, repository, health, and metrics Mongo auto-configurations while the enable property is absent
|
||||||
|
or false. It must allow them unchanged when the property is true; consumers must not need to set
|
||||||
|
`spring.autoconfigure.exclude`.
|
||||||
|
|
||||||
|
Replacement tests must prove:
|
||||||
|
|
||||||
|
- an actual `@EnableAutoConfiguration` context in default/false mode creates no Mongo
|
||||||
|
infrastructure;
|
||||||
|
- properties bind the enable flag;
|
||||||
|
- enabled mode can create the infrastructure with a supplied mock `MongoClient`, without a real
|
||||||
|
network connection;
|
||||||
|
- production source contains no `Example*` type.
|
||||||
|
|
||||||
|
The Testcontainers dependencies leave this module when the example repository IT is removed.
|
||||||
|
|
||||||
|
## 8. Correlation context boundary
|
||||||
|
|
||||||
|
`application-core` owns a framework-free `CorrelationIdPort` whose read result is optional.
|
||||||
|
`adapter:inbound:web` implements it from the sanitized request MDC correlation key.
|
||||||
|
`CreateWorkLogUseCase` and `PosterEventPublisher` depend only on the port and preserve the current
|
||||||
|
fallback to the generated event id when no correlation id exists.
|
||||||
|
|
||||||
|
Tests first pin present/blank/absent behavior and prove the sample application packages no longer
|
||||||
|
import SLF4J/MDC. Diagnostic storage remains an adapter concern.
|
||||||
|
|
||||||
|
## 9. Runtime composition and generated state
|
||||||
|
|
||||||
|
`app-bootstrap` keeps its current default runtime modules. Its build description and README must
|
||||||
|
state that optional leaves require an explicit registry and composition-root dependency change.
|
||||||
|
Optional adapters remain independently buildable and testable.
|
||||||
|
|
||||||
|
The tracked four-byte `src/sample-portfolio/.jqwik-database` is generated runtime state. Delete it
|
||||||
|
and add `.jqwik-database` to `src/.gitignore`; retain jqwik itself because property tests use it.
|
||||||
|
|
||||||
|
## 10. Verification
|
||||||
|
|
||||||
|
Run focused compile/tests before and after each dependency group. Regenerate locks only through
|
||||||
|
each affected leaf's `:leaf-path:resolveAndLockAll --write-locks` task, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew check --console=plain
|
||||||
|
./gradlew test --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew verifyApplicationCoreDependencyPurity --console=plain
|
||||||
|
./gradlew verifyConfigurationPropertiesProcessor --console=plain
|
||||||
|
./gradlew verifyDependencyLocks --console=plain
|
||||||
|
./gradlew verifyPublicPathSnapshot verifyEnvKeys --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion requires fresh review, `git diff --check`, and an LLM Wiki branch note or an explicit
|
||||||
|
capture blocker for the mandated exact vault path.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+415
@@ -0,0 +1,415 @@
|
|||||||
|
# Fileserver R2 Control Plane and Provider Selection Design
|
||||||
|
|
||||||
|
- Date: 2026-07-28
|
||||||
|
- Status: 승인된 설계, 구현 전
|
||||||
|
- Scope: provider-neutral R2 control plane, explicit destination/provider selection, first
|
||||||
|
`local-persistent` qualification provider
|
||||||
|
- Parent:
|
||||||
|
[Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md)
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
현재 `LocalFilePublicationAdapter`의 single-node process-restart R1을 운영 topology의 기본값으로
|
||||||
|
승격하지 않는다. 이번 increment는 다음을 구현한다.
|
||||||
|
|
||||||
|
1. application에는 기존 provider-neutral `FilePublicationPort`만 유지한다.
|
||||||
|
2. adapter 내부에 destination binding, provider descriptor, durable operation/manifest/reference
|
||||||
|
control plane을 둔다.
|
||||||
|
3. 활성화된 Fileserver는 정확한 destination과 provider를 명시해야 하며 implicit local fallback을
|
||||||
|
금지한다.
|
||||||
|
4. 첫 qualification provider로 pre-provisioned persistent filesystem을 사용하는
|
||||||
|
`local-persistent`를 구현한다.
|
||||||
|
5. `shared-mounted`와 `sftp`가 같은 control-plane state machine을 재사용할 수 있게 하되 이번
|
||||||
|
increment에서 가짜 provider나 동작하지 않는 bean을 만들지 않는다.
|
||||||
|
|
||||||
|
`local-persistent`는 container writable layer나 임시 디렉터리를 의미하지 않는다. 단일 노드 또는
|
||||||
|
node-attached persistent volume과 private owner boundary가 증명된 환경만 대상으로 한다.
|
||||||
|
|
||||||
|
## 2. 비범위
|
||||||
|
|
||||||
|
이번 increment에 포함하지 않는다.
|
||||||
|
|
||||||
|
- NFS 또는 다른 shared mount의 multi-client correctness;
|
||||||
|
- SFTP SDK, connection pool, credential, OpenSSH qualification;
|
||||||
|
- cross-node producer fencing;
|
||||||
|
- background reaper, retention delete, quota reservation;
|
||||||
|
- metrics/tracing/health implementation;
|
||||||
|
- optional content read/delete/list API;
|
||||||
|
- object storage. Object storage는 별도 outbound leaf의 책임이다.
|
||||||
|
|
||||||
|
이 항목은 seam만 만들지 않는다. 실제 semantic provider를 구현하는 후속 increment에서만
|
||||||
|
dependency, bean, setting을 추가한다.
|
||||||
|
|
||||||
|
## 3. 검토한 접근
|
||||||
|
|
||||||
|
### A. 현재 local adapter를 바로 R2로 표시
|
||||||
|
|
||||||
|
설정과 change surface는 작지만 provider selector, terminal manifest, opaque-reference direct
|
||||||
|
lookup과 strict startup evidence가 없다. R2를 과장하므로 선택하지 않는다.
|
||||||
|
|
||||||
|
### B. Local, NFS, SFTP를 동시에 구현
|
||||||
|
|
||||||
|
최종 기능은 많지만 서로 다른 보장과 real-service CI가 한 change surface에 결합된다. NFS와
|
||||||
|
OpenSSH 인프라가 없으면 검증되지 않은 provider가 남으므로 선택하지 않는다.
|
||||||
|
|
||||||
|
### C. Provider-neutral control plane + local-persistent 첫 qualification
|
||||||
|
|
||||||
|
공통 state machine과 binding을 먼저 고정하고 한 provider를 실제 crash/security 테스트로
|
||||||
|
qualification한다. 이후 provider가 control-plane 계약을 재사용하면서도 각자의 보장을 별도로
|
||||||
|
증명할 수 있다. 이 접근을 선택한다.
|
||||||
|
|
||||||
|
## 4. 계층과 모듈 경계
|
||||||
|
|
||||||
|
```text
|
||||||
|
application-core
|
||||||
|
FilePublicationPort
|
||||||
|
FilePublishRequest
|
||||||
|
FilePublishReceipt
|
||||||
|
|
|
||||||
|
v
|
||||||
|
adapter:outbound:fileserver
|
||||||
|
RoutingFilePublicationAdapter
|
||||||
|
|
|
||||||
|
+-- DestinationBindingRegistry
|
||||||
|
+-- FilePublicationProviderRegistry
|
||||||
|
+-- DurablePublicationCoordinator
|
||||||
|
+-- ProviderControlPlane
|
||||||
|
|
|
||||||
|
+-- LocalPersistentPublicationProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
- application/domain에는 provider ID, filesystem path, manifest locator, Spring 또는 NIO 타입을
|
||||||
|
추가하지 않는다.
|
||||||
|
- `RoutingFilePublicationAdapter`만 production `FilePublicationPort` bean이다.
|
||||||
|
- provider와 control-plane SPI는 fileserver package 내부 타입이다. 범용 filesystem/SDK API를
|
||||||
|
public bean으로 노출하지 않는다.
|
||||||
|
- `shared-mounted`와 `sftp` 타입 값은 구현 전까지 accepted setting으로 등록하지 않는다.
|
||||||
|
|
||||||
|
## 5. Application 계약 변경
|
||||||
|
|
||||||
|
기존 request와 opaque reference를 유지한다. R2 provider가 달성한 보장을 정확히 보고할 수 있도록
|
||||||
|
`FilePublishReceipt.DurabilityGuarantee`에 다음 값만 추가한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
FILE_AND_DIRECTORY_SYNC
|
||||||
|
```
|
||||||
|
|
||||||
|
이 값은 startup probe와 process-crash qualification을 모두 통과한 provider만 반환한다.
|
||||||
|
호출한 sync가 물리 device, volume replica 또는 storage-controller power-loss protection까지
|
||||||
|
완료됐다는 뜻은 아니다. 그 축은 deployment/storage evidence로 별도 판정한다.
|
||||||
|
`PROCESS_LOCAL_SYNC` 또는 `PROVIDER_ACK_ONLY`를 요구 보장보다 약한 상태에서 자동으로 R2 값으로
|
||||||
|
올리지 않는다.
|
||||||
|
|
||||||
|
새 opaque reference 형식은 다음 의미를 가지되 application은 내부 segment를 해석하지 않는다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
fsr1.<route-token>.<file-id>.<check-digits>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `route-token`: startup에서 생성된 bounded destination route allowlist 값;
|
||||||
|
- `file-id`: CSPRNG 128-bit 이상;
|
||||||
|
- `check-digits`: accidental truncation/corruption 검출;
|
||||||
|
- provider locator, operation ID, tenant/user ID, host/path는 포함하지 않는다.
|
||||||
|
|
||||||
|
Reference는 authorization token이 아니다. authorization은 application use case의 책임이다.
|
||||||
|
|
||||||
|
## 6. 명시적 설정과 선택
|
||||||
|
|
||||||
|
새 canonical prefix는 `app.fileserver`다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
fileserver:
|
||||||
|
enabled: false
|
||||||
|
destinations:
|
||||||
|
local-export:
|
||||||
|
provider-ref: local-primary
|
||||||
|
required-publication: unique-atomic-create
|
||||||
|
required-durability: file-and-directory-sync
|
||||||
|
maximum-rows: 1000000
|
||||||
|
maximum-encoded-bytes: 1073741824
|
||||||
|
providers:
|
||||||
|
local-primary:
|
||||||
|
type: local-persistent
|
||||||
|
root-directory: ${APP_FILESERVER_LOCAL_ROOT:}
|
||||||
|
auto-create: false
|
||||||
|
strict-path-security: true
|
||||||
|
expected-file-store-name: ${APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME:}
|
||||||
|
expected-file-store-type: ${APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE:}
|
||||||
|
mount-sentinel-name: .ca-fileserver-volume
|
||||||
|
mount-sentinel-sha256: ${APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256:}
|
||||||
|
expected-owner: ${APP_FILESERVER_LOCAL_EXPECTED_OWNER:}
|
||||||
|
maximum-root-mode: "0750"
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- `enabled=true`이면 destination과 provider가 각각 하나 이상 필요하다.
|
||||||
|
- 모든 destination은 존재하는 provider 하나를 참조한다.
|
||||||
|
- request destination에 binding이 없으면 producer 호출 전에 실패한다.
|
||||||
|
- provider type의 기본값은 없다.
|
||||||
|
- `local-persistent` root는 absolute, existing, pre-provisioned directory여야 한다.
|
||||||
|
- `auto-create=true`는 `local-persistent`에서 거부한다.
|
||||||
|
- root와 mount sentinel은 operator가 미리 만든다. Root attestation이 끝난 뒤 adapter가 private
|
||||||
|
top-level control/data directory와 bounded hash shard를 restrictive POSIX creation mode로
|
||||||
|
생성할 수 있으며, 생성할 때마다 parent identity와 directory sync를 확인한다.
|
||||||
|
- container ephemeral 경로를 위한 `local-dev`는 별도 후속 profile이다. production 설정과
|
||||||
|
같은 guarantee를 공유하지 않는다.
|
||||||
|
- 기존 `ca-skeleton.fileserver.*`는 R1/legacy compatibility selector로만 남는다. 새 R2 설정과
|
||||||
|
동시에 활성화되면 startup을 실패시킨다. 암묵 migration이나 precedence를 두지 않는다.
|
||||||
|
|
||||||
|
## 7. Startup capability compilation
|
||||||
|
|
||||||
|
application traffic을 받기 전에 destination별 effective descriptor를 한 번 compile한다.
|
||||||
|
|
||||||
|
`local-persistent`는 다음을 모두 검증한다.
|
||||||
|
|
||||||
|
1. root와 모든 ancestor가 symbolic link가 아니다.
|
||||||
|
2. root real path가 설정 absolute path와 일치한다.
|
||||||
|
3. configured owner와 실제 owner가 일치한다.
|
||||||
|
4. POSIX permission이 configured maximum보다 넓지 않고 group/world writable이 아니다.
|
||||||
|
5. `FileStore.name()`과 `type()`이 설정 값과 일치한다.
|
||||||
|
6. mount sentinel이 regular no-follow file이고 configured SHA-256와 일치한다.
|
||||||
|
7. data, staging, operations, manifests, references, quarantine directory가 같은
|
||||||
|
`FileStore`에 있다.
|
||||||
|
8. control directory는 private owner boundary이며 symlink가 아니다.
|
||||||
|
9. `SecureDirectoryStream`을 열 수 있다.
|
||||||
|
10. exclusive create, file force, hard-link create, directory force가 private probe directory에서
|
||||||
|
성공한다.
|
||||||
|
|
||||||
|
Probe artifact는 unique name만 사용하며 successful cleanup과 parent directory force까지
|
||||||
|
완료해야 한다. Probe 실패는 capability downgrade가 아니라 startup failure다.
|
||||||
|
|
||||||
|
JDK가 directory-relative hard-link primitive를 제공하지 않으므로 hard-link publish는 다음
|
||||||
|
boundary에서만 허용한다.
|
||||||
|
|
||||||
|
- root/control/data directories가 adapter owner 전용이고 untrusted writer가 없음;
|
||||||
|
- publish 직전과 직후 root identity, directory file key, mount sentinel을 다시 확인;
|
||||||
|
- target은 CSPRNG unique name;
|
||||||
|
- pre/post identity가 바뀌면 성공을 반환하지 않고 `PUBLISH_INDETERMINATE`;
|
||||||
|
- privileged host administrator 또는 same-owner malicious process와의 경쟁은 guarantee 범위가
|
||||||
|
아니며 deployment isolation requirement로 기록한다.
|
||||||
|
|
||||||
|
untrusted writer가 같은 root에 entry를 만들 수 있는 환경은 strict local R2가 아니다.
|
||||||
|
|
||||||
|
## 8. Durable control plane
|
||||||
|
|
||||||
|
```text
|
||||||
|
.ca-fileserver/
|
||||||
|
operations/<prefix>/<operation-id>.json
|
||||||
|
manifests/<prefix>/<file-id>.json
|
||||||
|
references/<prefix>/<file-id>.json
|
||||||
|
staging/<prefix>/<operation-id>.part
|
||||||
|
quarantine/
|
||||||
|
probe/
|
||||||
|
data/<prefix>/<generated-file-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
모든 locator는 validated single segment 또는 adapter가 생성한 bounded relative segment다.
|
||||||
|
Caller path를 받지 않는다.
|
||||||
|
|
||||||
|
### 8.1 Operation journal v2
|
||||||
|
|
||||||
|
필수 필드:
|
||||||
|
|
||||||
|
```text
|
||||||
|
schemaVersion
|
||||||
|
stateRevision
|
||||||
|
state
|
||||||
|
operationId
|
||||||
|
requestFingerprint
|
||||||
|
effectivePolicyRevision
|
||||||
|
effectivePolicyDigest
|
||||||
|
destinationId
|
||||||
|
providerId
|
||||||
|
fileId
|
||||||
|
routeToken
|
||||||
|
publishedFileName
|
||||||
|
stageFileName
|
||||||
|
byteSize
|
||||||
|
rowCount
|
||||||
|
columnCount
|
||||||
|
sha256
|
||||||
|
formulaMitigatedCount
|
||||||
|
manifestDigest
|
||||||
|
referenceDigest
|
||||||
|
createdAt
|
||||||
|
sealedAt
|
||||||
|
publishedAt
|
||||||
|
lastFailureCode
|
||||||
|
receiptSnapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
State는 `WRITING`, `SEALED`, `DATA_PUBLISHED`, `MANIFEST_PUBLISHED`,
|
||||||
|
`REFERENCE_PUBLISHED`, `PUBLISHED`, `QUARANTINED`다.
|
||||||
|
|
||||||
|
### 8.2 Private manifest v1
|
||||||
|
|
||||||
|
Manifest는 operation/file/provider/reference/fingerprint, schema·format·policy digest, byte/count,
|
||||||
|
SHA-256, achieved guarantees, internal relative locator를 기록한다. Absolute path, raw row/cell,
|
||||||
|
credential, raw tenant/user ID는 저장하지 않는다.
|
||||||
|
|
||||||
|
### 8.3 Reference index v1
|
||||||
|
|
||||||
|
Reference index는 opaque `file-id`에서 operation ID, file version, manifest digest와 internal
|
||||||
|
relative locator로 direct lookup한다. Directory scan은 receipt restoration의 authority가 아니다.
|
||||||
|
|
||||||
|
### 8.4 Record update
|
||||||
|
|
||||||
|
각 control record는:
|
||||||
|
|
||||||
|
1. sibling private temp file을 `CREATE_NEW`;
|
||||||
|
2. bounded canonical JSON encoding;
|
||||||
|
3. file `force(true)`;
|
||||||
|
4. same-directory atomic replace;
|
||||||
|
5. parent directory force;
|
||||||
|
6. read-back schema/revision/digest verification;
|
||||||
|
|
||||||
|
순서로 갱신한다. 낮은 revision, fingerprint mismatch, newer schema는 자동 덮어쓰지 않는다.
|
||||||
|
|
||||||
|
## 9. Publication ordering
|
||||||
|
|
||||||
|
```text
|
||||||
|
J-WRITING
|
||||||
|
-> stage stream/force
|
||||||
|
J-SEALED
|
||||||
|
-> exclusive hard-link data publish
|
||||||
|
-> data directory force
|
||||||
|
J-DATA_PUBLISHED
|
||||||
|
-> private manifest publish/force
|
||||||
|
J-MANIFEST_PUBLISHED
|
||||||
|
-> reference index publish/force
|
||||||
|
J-REFERENCE_PUBLISHED
|
||||||
|
-> terminal journal + receipt snapshot publish/force
|
||||||
|
J-PUBLISHED
|
||||||
|
-> receipt return
|
||||||
|
```
|
||||||
|
|
||||||
|
- Producer는 accepted attempt에서 최대 한 번 호출한다.
|
||||||
|
- `SEALED` 이후 retry/recovery는 staged bytes만 사용한다.
|
||||||
|
- terminal journal force 전에는 receipt를 반환하지 않는다.
|
||||||
|
- target collision, digest mismatch 또는 root identity change는 자동 overwrite하지 않는다.
|
||||||
|
- final data가 있어도 manifest/reference가 없으면 아직 terminal success가 아니다.
|
||||||
|
|
||||||
|
## 10. Deterministic recovery
|
||||||
|
|
||||||
|
Recovery는 operation ID direct lookup으로 실행하며 startup full scan에 의존하지 않는다.
|
||||||
|
|
||||||
|
| 확인된 상태 | 조치 |
|
||||||
|
| --- | --- |
|
||||||
|
| terminal journal + matching manifest/reference/data | 저장된 receipt 복원 |
|
||||||
|
| SEALED + valid stage, data 없음 | data publication부터 재개 |
|
||||||
|
| SEALED + matching data | manifest publication부터 재개 |
|
||||||
|
| DATA_PUBLISHED + matching data | manifest publication 재개 |
|
||||||
|
| MANIFEST_PUBLISHED + matching manifest/data | reference publication 재개 |
|
||||||
|
| REFERENCE_PUBLISHED + all matching | terminal journal 완성 |
|
||||||
|
| data digest mismatch | `QUARANTINED`, integrity failure |
|
||||||
|
| marker/manifest/reference schema newer | 보존 후 fail-fast/quarantine |
|
||||||
|
| fingerprint conflict | typed conflict, 기존 artifact 보존 |
|
||||||
|
| root/mount identity change | indeterminate, write/recovery 중단 |
|
||||||
|
|
||||||
|
Truth priority:
|
||||||
|
|
||||||
|
```text
|
||||||
|
matching data + private manifest + reference
|
||||||
|
> terminal operation record
|
||||||
|
> non-terminal operation record
|
||||||
|
> in-memory state
|
||||||
|
```
|
||||||
|
|
||||||
|
모순이 있으면 임의 성공이나 삭제 대신 quarantine evidence를 기록한다.
|
||||||
|
|
||||||
|
## 11. Compatibility
|
||||||
|
|
||||||
|
- R1 journal schema v1은 읽을 수 있어야 한다.
|
||||||
|
- R1 terminal receipt는 기존 `PROCESS_LOCAL_SYNC` 보장 그대로 복원한다.
|
||||||
|
- R1 artifact를 자동으로 R2 manifest/reference로 승격하지 않는다.
|
||||||
|
- R2 writer는 journal v2만 생성한다.
|
||||||
|
- 기존 overwrite-capable legacy port는 별도 root와 opt-in을 유지하며 R2 control plane에 접근하지
|
||||||
|
않는다.
|
||||||
|
- R1과 R2 selector가 동시에 활성화되면 ambiguous composition으로 startup을 실패시킨다.
|
||||||
|
|
||||||
|
## 12. Failure semantics
|
||||||
|
|
||||||
|
- 설정/보장 mismatch: startup failure;
|
||||||
|
- destination 없음: producer 전 deterministic request failure;
|
||||||
|
- stage 이전 capacity/validation failure: not applied;
|
||||||
|
- stage/write failure: failed, partial stage는 recovery evidence가 아니면 정리;
|
||||||
|
- sealed 이후 filesystem timeout/IO/root identity change: indeterminate;
|
||||||
|
- published data와 metadata 불일치: integrity/quarantine;
|
||||||
|
- journal/control record corruption: provider exception을 노출하지 않고 typed indeterminate;
|
||||||
|
- guarantee를 낮춰 성공시키는 fallback은 없다.
|
||||||
|
|
||||||
|
## 13. 테스트와 증거
|
||||||
|
|
||||||
|
### 13.1 Unit/contract
|
||||||
|
|
||||||
|
- exact destination/provider selection과 no-default;
|
||||||
|
- R1/R2 simultaneous activation rejection;
|
||||||
|
- reference grammar/check digits/forged route rejection;
|
||||||
|
- journal v2, manifest, reference canonical round-trip;
|
||||||
|
- state revision과 fingerprint conflict;
|
||||||
|
- achieved durability value invariants.
|
||||||
|
|
||||||
|
### 13.2 Local integration
|
||||||
|
|
||||||
|
- pre-provisioned root requirement;
|
||||||
|
- owner/mode/FileStore/sentinel mismatch startup failure;
|
||||||
|
- symlink ancestor/control/data rejection;
|
||||||
|
- staging/final/control same `FileStore`;
|
||||||
|
- successful capability probe와 cleanup;
|
||||||
|
- partial final visibility 0건;
|
||||||
|
- same operation concurrency와 producer once;
|
||||||
|
- target collision no overwrite;
|
||||||
|
- data/manifest/reference digest mismatch quarantine.
|
||||||
|
|
||||||
|
### 13.3 Crash qualification
|
||||||
|
|
||||||
|
Forked JVM helper를 사용해 다음 force boundary 직후 process를 강제 종료하고 새 JVM에서 같은
|
||||||
|
operation을 재시도한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
J-WRITING
|
||||||
|
stage force
|
||||||
|
J-SEALED
|
||||||
|
data link
|
||||||
|
data directory force
|
||||||
|
manifest force
|
||||||
|
manifest directory force
|
||||||
|
reference force
|
||||||
|
reference directory force
|
||||||
|
terminal journal force
|
||||||
|
terminal journal directory force
|
||||||
|
```
|
||||||
|
|
||||||
|
각 boundary에서 결과는 다음 중 하나여야 한다.
|
||||||
|
|
||||||
|
- producer 재실행 없이 동일 receipt 복원;
|
||||||
|
- verified sealed bytes로 publication 완성;
|
||||||
|
- typed indeterminate/quarantine.
|
||||||
|
|
||||||
|
partial final, overwrite, 다른 receipt, silent guarantee downgrade는 허용하지 않는다.
|
||||||
|
|
||||||
|
### 13.4 플랫폼
|
||||||
|
|
||||||
|
- Linux/POSIX + `SecureDirectoryStream` + directory force qualification lane에서만
|
||||||
|
`FILE_AND_DIRECTORY_SYNC`을 검증한다.
|
||||||
|
- capability가 없는 일반 unit-test filesystem에서는 R1 보장만 테스트하며 R2 service test를
|
||||||
|
skip 성공으로 처리하지 않는다.
|
||||||
|
|
||||||
|
## 14. 완료 기준
|
||||||
|
|
||||||
|
이번 increment의 완료는 “Fileserver 전체가 모든 운영환경에서 R2”라는 뜻이 아니다.
|
||||||
|
|
||||||
|
완료를 주장하려면:
|
||||||
|
|
||||||
|
1. provider 기본값 없이 exact binding이 동작한다.
|
||||||
|
2. `local-persistent` startup probe가 모든 required capability를 증명한다.
|
||||||
|
3. terminal manifest/reference direct lookup이 구현된다.
|
||||||
|
4. 모든 publication force boundary의 crash test가 deterministic result를 낸다.
|
||||||
|
5. strict path/mount identity/security tests가 통과한다.
|
||||||
|
6. public path와 clean architecture gate가 통과한다.
|
||||||
|
7. R1 compatibility artifact를 R2로 자동 승격하지 않는다.
|
||||||
|
8. 문서와 receipt는 `local-persistent` qualification만 R2라고 표시한다.
|
||||||
|
|
||||||
|
후속 순서는 Phase 3 maintenance/resource limits, Phase 4 SFTP, Phase 5 shared-mounted/NFS evidence다.
|
||||||
@@ -23,6 +23,19 @@ APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
|
|||||||
|
|
||||||
# ----- Optional integration adapters (default: all disabled) -----
|
# ----- Optional integration adapters (default: all disabled) -----
|
||||||
APP_CACHE_REDIS_ENABLED=false
|
APP_CACHE_REDIS_ENABLED=false
|
||||||
|
APP_CACHE_REDIS_CLIENT_MODE=managed
|
||||||
|
APP_CACHE_REDIS_HOST=localhost
|
||||||
|
APP_CACHE_REDIS_PORT=6379
|
||||||
|
APP_CACHE_REDIS_PASSWORD=
|
||||||
|
APP_CACHE_REDIS_KEY_HMAC_SECRET=
|
||||||
|
APP_CACHE_REDIS_COMMAND_TIMEOUT=2s
|
||||||
|
APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS=8
|
||||||
|
APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216
|
||||||
|
APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local
|
||||||
|
APP_CACHE_REDIS_SEMANTIC_REGION=default
|
||||||
|
APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576
|
||||||
|
APP_CACHE_DEFAULT_TTL=300s
|
||||||
|
APP_CACHE_NEGATIVE_TTL=60s
|
||||||
APP_MESSAGING_BROKER=
|
APP_MESSAGING_BROKER=
|
||||||
APP_MESSAGING_KAFKA_BROKERS=
|
APP_MESSAGING_KAFKA_BROKERS=
|
||||||
APP_NOTIFICATION_SLACK_PROVIDER=
|
APP_NOTIFICATION_SLACK_PROVIDER=
|
||||||
@@ -32,6 +45,7 @@ APP_NOTIFICATION_EMAIL_PROVIDER=
|
|||||||
APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s
|
APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s
|
||||||
APP_OUTBOUND_HTTP_READ_TIMEOUT=5s
|
APP_OUTBOUND_HTTP_READ_TIMEOUT=5s
|
||||||
APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s
|
APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s
|
||||||
|
APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS=128
|
||||||
APP_OUTBOUND_HTTP_RETRY_ENABLED=false
|
APP_OUTBOUND_HTTP_RETRY_ENABLED=false
|
||||||
APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS=3
|
APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS=3
|
||||||
APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms
|
APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms
|
||||||
|
|||||||
@@ -10,5 +10,8 @@ build/
|
|||||||
.classpath
|
.classpath
|
||||||
.settings/
|
.settings/
|
||||||
|
|
||||||
|
# jqwik property-test runtime state
|
||||||
|
.jqwik-database
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+15
-9
@@ -77,12 +77,12 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
|||||||
|
|
||||||
### `verifyCleanArchitectureDependencies`
|
### `verifyCleanArchitectureDependencies`
|
||||||
|
|
||||||
- **하는 일.** `allowedProjectDependencies` 맵에 모듈별로 허용된 의존 대상을 선언하고, 실제 Gradle
|
- **하는 일.** [config/architecture/modules.json](config/architecture/modules.json)의
|
||||||
프로젝트 의존(`api` / `implementation` / `compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를
|
`allowed_dependencies`를 읽고, 실제 Gradle 프로젝트 의존(`api` / `implementation` /
|
||||||
실패시킵니다.
|
`compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를 실패시킵니다.
|
||||||
- **이 맵이 의존 방향의 SSOT 입니다.** 새 모듈이나 새 의존 edge 를 추가하면 이 맵과 ArchUnit
|
- **JSON registry가 의존 방향의 SSOT 입니다.** 새 모듈이나 새 production 의존 edge를 추가하면
|
||||||
규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. 모르는 코드를 검사하지 못하는 게이트는
|
registry와 ArchUnit 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. settings와 gate는
|
||||||
보호 기능을 못 합니다.
|
같은 registry를 읽고, 등록되지 않은 leaf나 허용되지 않은 edge를 fail-closed로 거부합니다.
|
||||||
|
|
||||||
### `verifyOneTypePerFile` (code-conventions I6)
|
### `verifyOneTypePerFile` (code-conventions I6)
|
||||||
|
|
||||||
@@ -121,15 +121,17 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
|||||||
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
|
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
|
||||||
- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange` 로
|
- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange` 로
|
||||||
snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후
|
snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후
|
||||||
반영합니다.
|
재생성된 snapshot 을 함께 커밋합니다.
|
||||||
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
|
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
|
||||||
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
|
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
|
||||||
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
|
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
|
||||||
`RequestMatcherDelegatingAuthorizationManager` 의 private 필드에 숨어 있음). 그래서 `permitAll()`
|
`RequestMatcherDelegatingAuthorizationManager` 의 private 필드에 숨어 있음). 그래서 `permitAll()`
|
||||||
을 실제로 먹이는 결정적 SSOT 인 `SECURITY_PUBLIC_PATHS` 자체를 snapshot 합니다. 탐지 목표(공개 경로
|
을 실제로 먹이는 결정적 SSOT 인 `SECURITY_PUBLIC_PATHS` 자체를 snapshot 합니다. 탐지 목표(공개 경로
|
||||||
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
|
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
|
||||||
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. `docs/` 는 gitignore 대상이라, fresh
|
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. 이 파일은 커밋된 필수 보안
|
||||||
checkout 에서는 snapshot 이 없으므로 "처음엔 만들고 통과"한 뒤 이후 변경부터 감시합니다.
|
baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh
|
||||||
|
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 위 명령으로 재생성한 뒤
|
||||||
|
보안 리뷰와 함께 커밋합니다.
|
||||||
|
|
||||||
### `verifyTrivyignore`
|
### `verifyTrivyignore`
|
||||||
|
|
||||||
@@ -226,6 +228,8 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
|||||||
fail-fast sentinel 이 포트를 충족합니다(Layer 3).
|
fail-fast sentinel 이 포트를 충족합니다(Layer 3).
|
||||||
|
|
||||||
- **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`.
|
- **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`.
|
||||||
|
- **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가
|
||||||
|
제공한 `RedisClient` bean을 사용합니다.
|
||||||
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시
|
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시
|
||||||
fail-fast).
|
fail-fast).
|
||||||
- **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수,
|
- **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수,
|
||||||
@@ -243,6 +247,8 @@ fail-fast sentinel 이 포트를 충족합니다(Layer 3).
|
|||||||
- **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero.
|
- **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero.
|
||||||
- **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예:
|
- **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예:
|
||||||
`10s`), 필수, non-zero.
|
`10s`), 필수, non-zero.
|
||||||
|
- **`APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS`** — client별 살아 있는 logical-call worker 상한.
|
||||||
|
기본값 `128`, 허용 범위 `1..10000`.
|
||||||
- **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이
|
- **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이
|
||||||
있어야 하며(D3 가드), 없으면 기동 실패.
|
있어야 하며(D3 가드), 없으면 기동 실패.
|
||||||
- retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존):
|
- retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존):
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
- Module ID: `adapter-inbound-graphql`
|
- Module ID: `adapter-inbound-graphql`
|
||||||
- Gradle path: `:adapter:inbound:graphql`
|
- Gradle path: `:adapter:inbound:graphql`
|
||||||
- Focused test: `./gradlew :adapter:inbound:graphql:test --console=plain`
|
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:graphql:test --console=plain`
|
||||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||||
|
|
||||||
Package root: `dev.caskeleton.adapter.inbound.graphql`.
|
Package root: `dev.caskeleton.adapter.inbound.graphql`.
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,10 @@
|
|||||||
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)'
|
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':application-core')
|
|
||||||
implementation project(':domain-core')
|
|
||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
|
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-graphql'
|
implementation 'org.springframework.boot:spring-boot-starter-graphql'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
|
||||||
|
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
|
||||||
|
|
||||||
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
|
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
|
||||||
// controller through a real AnnotatedControllerConfigurer and drives it with an
|
// controller through a real AnnotatedControllerConfigurer and drives it with an
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspa
|
|||||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
|
||||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
|
||||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
|
||||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
|
||||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||||
@@ -121,7 +117,6 @@ org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClass
|
|||||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
|
||||||
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
- Module ID: `adapter-inbound-grpc`
|
- Module ID: `adapter-inbound-grpc`
|
||||||
- Gradle path: `:adapter:inbound:grpc`
|
- Gradle path: `:adapter:inbound:grpc`
|
||||||
- Focused test: `./gradlew :adapter:inbound:grpc:test --console=plain`
|
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:grpc:test --console=plain`
|
||||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||||
|
|
||||||
Package root: `dev.caskeleton.adapter.inbound.grpc`.
|
Package root: `dev.caskeleton.adapter.inbound.grpc`.
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,16 @@ dependencyManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':application-core')
|
|
||||||
implementation project(':domain-core')
|
|
||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
|
|
||||||
implementation 'org.springframework.boot:spring-boot-starter'
|
implementation 'org.springframework.boot:spring-boot-starter'
|
||||||
|
|
||||||
implementation 'io.grpc:grpc-netty-shaded'
|
implementation 'io.grpc:grpc-netty-shaded'
|
||||||
implementation 'io.grpc:grpc-protobuf'
|
|
||||||
implementation 'io.grpc:grpc-stub'
|
|
||||||
implementation 'io.grpc:grpc-services' // health + reflection (grpc.health.v1 / reflection)
|
implementation 'io.grpc:grpc-services' // health + reflection (grpc.health.v1 / reflection)
|
||||||
|
|
||||||
// grpc-java generated stubs reference javax.annotation.Generated; kept compileOnly for parity
|
|
||||||
// with the feature module (the skeleton itself generates no stubs).
|
|
||||||
compileOnly 'org.apache.tomcat:annotations-api:6.0.53'
|
|
||||||
|
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||||
|
|
||||||
|
// The boot test directly builds generated health/reflection protobuf messages. grpc-services
|
||||||
|
// does not expose protobuf-java on its compile API, so keep the narrower test-only declaration.
|
||||||
|
testImplementation 'io.grpc:grpc-protobuf'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClass
|
|||||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||||
com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath
|
com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath
|
||||||
com.google.api.grpc:proto-google-common-protos:2.41.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
com.google.api.grpc:proto-google-common-protos:2.41.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||||
@@ -37,7 +37,7 @@ com.google.j2objc:j2objc-annotations:2.8=runtimeClasspath,testRuntimeClasspath
|
|||||||
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
||||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||||
com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath
|
com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath
|
||||||
com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||||
@@ -54,7 +54,7 @@ io.grpc:grpc-context:1.68.1=runtimeClasspath,testRuntimeClasspath
|
|||||||
io.grpc:grpc-core:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.grpc:grpc-core:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.grpc:grpc-netty-shaded:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.grpc:grpc-netty-shaded:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.grpc:grpc-protobuf-lite:1.68.1=runtimeClasspath,testRuntimeClasspath
|
io.grpc:grpc-protobuf-lite:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||||
io.grpc:grpc-protobuf:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.grpc:grpc-protobuf:1.68.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath
|
io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||||
@@ -88,7 +88,6 @@ org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
|||||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||||
org.apache.tomcat:annotations-api:6.0.53=compileClasspath
|
|
||||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
- Module ID: `adapter-inbound-web`
|
- Module ID: `adapter-inbound-web`
|
||||||
- Gradle path: `:adapter:inbound:web`
|
- Gradle path: `:adapter:inbound:web`
|
||||||
- Focused test: `./gradlew :adapter:inbound:web:test --console=plain`
|
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:web:test --console=plain`
|
||||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||||
|
|
||||||
Package root: `dev.caskeleton.adapter.inbound.web`.
|
Package root: `dev.caskeleton.adapter.inbound.web`.
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ Package root: `dev.caskeleton.adapter.inbound.web`.
|
|||||||
- Request/response DTOs.
|
- Request/response DTOs.
|
||||||
- Request DTO to application command mapping.
|
- Request DTO to application command mapping.
|
||||||
- Authentication, validation, error mapping, filters, and web/security settings.
|
- Authentication, validation, error mapping, filters, and web/security settings.
|
||||||
|
- Sanitized request correlation context exposed through application-owned `CorrelationIdPort`.
|
||||||
|
- Transport-owned OpenAPI customization that keeps `ApiError.details` as `type: object` without
|
||||||
|
leaking Swagger dependencies into `shared-contract`.
|
||||||
|
|
||||||
## Allowed
|
## Allowed
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## OpenAPI contract stabilization
|
||||||
|
|
||||||
|
Springdoc 3 represents an untyped Java `Object` as an unconstrained OAS 3.1 schema (`{}`).
|
||||||
|
`OpenApiContractConfig` owns the transport-specific correction for the shared `ApiError.details`
|
||||||
|
field and publishes it as `type: object`. This preserves the committed HTTP contract without adding
|
||||||
|
Swagger annotations or dependencies to `shared-contract`. Real-server OpenAPI tests import this
|
||||||
|
production configuration and compare the result with the committed snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## auth — 인증 (OIDC resource server)
|
## auth — 인증 (OIDC resource server)
|
||||||
|
|
||||||
### SecurityConfig
|
### SecurityConfig
|
||||||
@@ -323,6 +333,12 @@
|
|||||||
같은 논리 ID 의 envelope 형태(camelCase)와 HTTP 헤더 형태(kebab-case)는 D19 projection 이며,
|
같은 논리 ID 의 envelope 형태(camelCase)와 HTTP 헤더 형태(kebab-case)는 D19 projection 이며,
|
||||||
변환 단일 지점은 `ResponseMetaFactory`.
|
변환 단일 지점은 `ResponseMetaFactory`.
|
||||||
|
|
||||||
|
### MdcCorrelationIdPortAdapter
|
||||||
|
- `RequestLoggingFilter`가 무해화하고 MDC `correlation_id`에 넣은 값을 application-core의
|
||||||
|
`CorrelationIdPort`로 투영한다.
|
||||||
|
- absent/blank는 `Optional.empty()`로 반환한다. application/sample 계층은 SLF4J/MDC를 직접
|
||||||
|
참조하지 않고 event-id fallback 정책만 소유한다.
|
||||||
|
|
||||||
### HeaderSanitizer
|
### HeaderSanitizer
|
||||||
- 인바운드 헤더 값을 MDC/로그 도달 전에 무해화(D14, OWASP-LOG-C3/C5, CWE-117). 스켈레톤은 구조화 JSON 로깅을
|
- 인바운드 헤더 값을 MDC/로그 도달 전에 무해화(D14, OWASP-LOG-C3/C5, CWE-117). 스켈레톤은 구조화 JSON 로깅을
|
||||||
가정하므로 위협은 CR/LF/제어문자를 통한 로그 라인 위조 — 값은 보존하되 `\r`/`\n`/ASCII 제어문자(`< 0x20`)를
|
가정하므로 위협은 CR/LF/제어문자를 통한 로그 라인 위조 — 값은 보존하되 `\r`/`\n`/ASCII 제어문자(`< 0x20`)를
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
// HTTP / web adapters. Depends on application, domain, and shared operational contracts.
|
// HTTP / web adapters. Depends on application and shared operational contracts.
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':domain-core')
|
|
||||||
implementation project(':application-core')
|
implementation project(':application-core')
|
||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.openapitools:jackson-databind-nullable:0.2.6'
|
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||||
|
implementation('org.openapitools:jackson-databind-nullable:0.2.6') {
|
||||||
|
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind'
|
||||||
|
}
|
||||||
// feature-api-contract-baseline D10: OpenAPI producer. springdoc exposes the
|
// feature-api-contract-baseline D10: OpenAPI producer. springdoc exposes the
|
||||||
// running app's machine-readable contract at /v3/api-docs (OAS 3.1, generated —
|
// running app's machine-readable contract at /v3/api-docs (OAS 3.1, generated —
|
||||||
// never a hand-maintained stale schema). The release-blocking drift gate is
|
// never a hand-maintained stale schema). The release-blocking drift gate is
|
||||||
// owned by feature-contract-verification-test-suite (planned).
|
// owned by feature-contract-verification-test-suite (planned).
|
||||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6'
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnota
|
|||||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.swagger.core.v3:swagger-core-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.swagger.core.v3:swagger-models-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
@@ -125,9 +125,10 @@ org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
|||||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||||
org.springdoc:springdoc-openapi-starter-common:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.Components;
|
||||||
|
import io.swagger.v3.oas.models.media.ObjectSchema;
|
||||||
|
import io.swagger.v3.oas.models.media.Schema;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springdoc.core.customizers.OpenApiCustomizer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps transport-owned OpenAPI schema details stable across springdoc library upgrades.
|
||||||
|
*
|
||||||
|
* <p>{@code ApiError.details} is represented by {@code Object} in the shared response contract.
|
||||||
|
* Springdoc 3 renders an untyped Java {@code Object} as an unconstrained OAS 3.1 schema. The public
|
||||||
|
* HTTP contract remains object-shaped, so the web adapter restores that transport-specific type
|
||||||
|
* without adding Swagger dependencies or annotations to {@code shared-contract}.
|
||||||
|
*/
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
public class OpenApiContractConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
OpenApiCustomizer apiErrorDetailsObjectSchemaCustomizer() {
|
||||||
|
return openApi -> {
|
||||||
|
Components components = openApi.getComponents();
|
||||||
|
Map<String, Schema> schemas = components == null ? null : components.getSchemas();
|
||||||
|
Schema<?> apiError = schemas == null ? null : schemas.get("ApiError");
|
||||||
|
Map<String, Schema> properties = apiError == null ? null : apiError.getProperties();
|
||||||
|
if (properties != null && properties.containsKey("details")) {
|
||||||
|
properties.put("details", new ObjectSchema());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.observability;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.observability.CorrelationIdPort;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Reads the current request's sanitized correlation identifier from the inbound web MDC. */
|
||||||
|
@Component
|
||||||
|
public class MdcCorrelationIdPortAdapter implements CorrelationIdPort {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<String> currentCorrelationId() {
|
||||||
|
return Optional.ofNullable(MDC.get(MdcKeys.CORRELATION_ID)).filter(value -> !value.isBlank());
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.config;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.openapitools.jackson.nullable.JsonNullable;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
class JacksonNullableConfigTest {
|
||||||
|
|
||||||
|
private final ObjectMapper mapper =
|
||||||
|
JsonMapper.builder().addModule(new JacksonNullableConfig().jsonNullableModule()).build();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsPresentValue() throws Exception {
|
||||||
|
Payload payload = mapper.readValue("{\"value\":\"configured\"}", Payload.class);
|
||||||
|
|
||||||
|
assertThat(payload.value().isPresent()).isTrue();
|
||||||
|
assertThat(payload.value().get()).isEqualTo("configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsExplicitNullAsPresentNull() throws Exception {
|
||||||
|
Payload payload = mapper.readValue("{\"value\":null}", Payload.class);
|
||||||
|
|
||||||
|
assertThat(payload.value().isPresent()).isTrue();
|
||||||
|
assertThat(payload.value().get()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsMissingPropertyAsUndefined() throws Exception {
|
||||||
|
Payload payload = mapper.readValue("{}", Payload.class);
|
||||||
|
|
||||||
|
assertThat(payload.value()).isNotNull();
|
||||||
|
assertThat(payload.value().isPresent()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Payload(JsonNullable<String> value) {}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.observability;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
|
||||||
|
class MdcCorrelationIdPortAdapterTest {
|
||||||
|
|
||||||
|
private final MdcCorrelationIdPortAdapter adapter = new MdcCorrelationIdPortAdapter();
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearMdc() {
|
||||||
|
MDC.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsSanitizedRequestCorrelationId() {
|
||||||
|
MDC.put(MdcKeys.CORRELATION_ID, "corr-123");
|
||||||
|
|
||||||
|
assertThat(adapter.currentCorrelationId()).contains("corr-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void treatsBlankMdcValueAsAbsent() {
|
||||||
|
MDC.put(MdcKeys.CORRELATION_ID, " ");
|
||||||
|
|
||||||
|
assertThat(adapter.currentCorrelationId()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsAbsenceWhenRequestContextIsMissing() {
|
||||||
|
assertThat(adapter.currentCorrelationId()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
- Module ID: `adapter-inbound-websocket`
|
- Module ID: `adapter-inbound-websocket`
|
||||||
- Gradle path: `:adapter:inbound:websocket`
|
- Gradle path: `:adapter:inbound:websocket`
|
||||||
- Focused test: `./gradlew :adapter:inbound:websocket:test --console=plain`
|
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:websocket:test --console=plain`
|
||||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||||
|
|
||||||
Package root: `dev.caskeleton.adapter.inbound.websocket`.
|
Package root: `dev.caskeleton.adapter.inbound.websocket`.
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,9 @@
|
|||||||
description = 'Inbound adapter: WebSocket (STOMP over SockJS, skeleton machinery)'
|
description = 'Inbound adapter: WebSocket (STOMP over SockJS, skeleton machinery)'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':application-core')
|
|
||||||
implementation project(':domain-core')
|
implementation project(':domain-core')
|
||||||
implementation project(':shared-contract')
|
|
||||||
|
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
|
||||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
|
||||||
|
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspa
|
|||||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
|
||||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
|
||||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
|
||||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
|
||||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||||
|
|||||||
@@ -4,25 +4,37 @@
|
|||||||
|
|
||||||
- Module ID: `adapter-outbound-cache-redis`
|
- Module ID: `adapter-outbound-cache-redis`
|
||||||
- Gradle path: `:adapter:outbound:cache-redis`
|
- Gradle path: `:adapter:outbound:cache-redis`
|
||||||
- Focused test: `./gradlew :adapter:outbound:cache-redis:test --console=plain`
|
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:cache-redis:test --console=plain`
|
||||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||||
|
|
||||||
Package root: `dev.caskeleton.adapter.outbound.cache`.
|
Package root: `dev.caskeleton.adapter.outbound.cache`.
|
||||||
|
|
||||||
## Responsibility
|
## Responsibility
|
||||||
|
|
||||||
- Implement cache stores, routing, Redis capability, and fail-open technical behavior behind ports.
|
- Implement semantic cache ports from `application-core` without exposing Redis concepts to core.
|
||||||
- Own cache binding settings and Redis client adaptation.
|
- Own canonical physical keys, digesting, codec/envelope, program catalog, typed Redis atomic
|
||||||
|
facades, runtime client adaptation, and capability-specific failure semantics.
|
||||||
|
- Keep the legacy cache router isolated while consumers migrate to semantic ports.
|
||||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
|
- Allowed dependency edges come only from the module's
|
||||||
|
`src/config/architecture/modules.json` entry.
|
||||||
- No inbound transport, persistence entity/repository, bootstrap, or sample dependency.
|
- No inbound transport, persistence entity/repository, bootstrap, or sample dependency.
|
||||||
- Cache adapters do not decide business freshness, entitlement, or domain fallback rules.
|
- Cache adapters do not decide business freshness, entitlement, or domain fallback rules.
|
||||||
|
- Application/domain code must not receive raw Redis keys, commands, Lua/Function names, SDK
|
||||||
|
objects, topology, or connection types.
|
||||||
|
- Cache fail-open behavior must not be reused for session, idempotency, strict quota, lease, or
|
||||||
|
fencing.
|
||||||
|
- The standalone runtime/cache service lane is R1 evidence only. Sentinel/Cluster, TLS/ACL,
|
||||||
|
persistence/restart, eviction and fault evidence are required separately for R2.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Use fake Redis clients and contract tests for routing/fail-open behavior. Do not use a real network in
|
Focused tests use fakes for contract, key, catalog, and typed-facade behavior. R1/R2 promotion
|
||||||
focused tests; configuration changes include binding/validation coverage.
|
requires a separate real Redis service lane; it may never be silently skipped when selected.
|
||||||
|
|
||||||
|
`redisServiceTest` is the explicit standalone lane. It fails when its host/port properties are
|
||||||
|
missing; the default unit task excludes its `redis-service` tag.
|
||||||
|
|||||||
@@ -1,37 +1,121 @@
|
|||||||
# adapter:outbound:cache-redis — 설계 결정 참조
|
# adapter:outbound:cache-redis — 설계 결정 참조
|
||||||
|
|
||||||
캐시 아웃바운드 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.outbound.cache`(`core` 서브
|
캐시/Redis 기술 capability 아웃바운드 모듈. 패키지 루트:
|
||||||
패키지에 라우팅/SPI 추상화, `redis` 서브패키지에 Redis 바인딩). `:adapter:outbound:support` 에
|
`dev.caskeleton.adapter.outbound.cache`. `application-core`의 provider-neutral cache contract를
|
||||||
의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다.
|
구현할 수 있는 경계와 Redis physical key/atomic-program 기반을 소유한다.
|
||||||
|
|
||||||
허용/금지 의존 정책은 `src/build.gradle` 의
|
허용/금지 의존 정책은 `src/config/architecture/modules.json`의
|
||||||
`allowedProjectDependencies['adapter:outbound:cache-redis']` 항목이 SSOT 다(이 모듈은 아직 별도
|
`adapter-outbound-cache-redis` 항목이 SSOT다. 상세 목표와 미구현 단계는
|
||||||
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
|
`docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`에 있다.
|
||||||
기록이다.
|
|
||||||
|
|
||||||
## 모듈 개요
|
## 현재 readiness
|
||||||
|
|
||||||
application-core 포트 뒤에 두는 **선택형** 캐시 어댑터다. `@ConditionalOnProperty`
|
현재 standalone runtime과 semantic string cache는 R1이다. 모듈이 Lettuce connection lifecycle,
|
||||||
(`APP_CACHE_REDIS_ENABLED`)로 게이팅되고 기본 비활성이다. `core` 서브패키지는 라우팅/SPI 추상화만
|
finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative
|
||||||
갖고, `redis` 서브패키지가 이 모듈이 기본 제공하는 유일한 구체 백엔드(`RedisCacheAdapterConfig`
|
TTL, digest-protected bounded binary envelope, HMAC physical key,
|
||||||
/ `RedisCacheStore`)다. 다른 벤더 백엔드가 필요하면 `CacheBackend` SPI 를 구현해 빈으로 추가한다.
|
invalidation, Lua `EVALSHA -> NOSCRIPT -> EVAL` 실행기를 제공한다.
|
||||||
|
`app.cache.redis.client-mode=external`이면 프로젝트가 제공한 `RedisClient` 호환 경로를 사용하고
|
||||||
|
managed connection을 생성하지 않는다.
|
||||||
|
|
||||||
## 중앙 fail-open 합성
|
명시적으로 Redis 7.4 image를 띄워 실행하는 standalone lane이 실제 expiry와
|
||||||
|
compare-and-delete Lua 실행을 검증하지만 Sentinel/Cluster,
|
||||||
|
TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가 없으므로 R2가 아니다.
|
||||||
|
|
||||||
`FailOpenCacheStore` 데코레이터는 `CacheRouterConfig` 가 모든 `CacheBackend` 에 **중앙에서**
|
## Application cache contract
|
||||||
적용한다 — 백엔드 설정이 실수로 fail-open 정책을 빠뜨릴 수 없다. 백엔드 실패는 cache-miss 로
|
|
||||||
다운그레이드돼 외부 장애가 5xx 로 번지지 않는다. 바인딩되지 않은 논리 이름은 설정 오류이며
|
|
||||||
라우터에서 fail-fast 한다(Layer 3).
|
|
||||||
|
|
||||||
## 기여 계약은 `CacheBackend`, SPI 는 `CacheStore`
|
`application-core`의 `CacheRegionPort<K,V>`는 다음을 분리한다.
|
||||||
|
|
||||||
기여(contribution) 타입을 `CacheStore` 가 아닌 `CacheBackend` 로 둔 건 의도적이다 — 임의의
|
- fresh/stale positive hit;
|
||||||
`CacheStore` 빈이 실수로 라우팅되지 않게 하고, 타입이 IDE 탐색 가능하며 중복 id 는 startup 을
|
- authoritative negative hit;
|
||||||
실패시킨다. `CacheStore.get()` 의 `Optional.empty()` 는 miss 를 뜻한다(SDK 타입이 어댑터 밖으로
|
- normal absent/expired/invalidated miss;
|
||||||
새지 않게 — B7).
|
- incompatible schema;
|
||||||
|
- unavailable/overloaded와 operation certainty;
|
||||||
|
- recorded/conditional/degraded/indeterminate mutation;
|
||||||
|
- invalidated/already-absent/degraded/indeterminate invalidation.
|
||||||
|
|
||||||
## 라우팅 바인딩
|
TTL, jitter, codec, topology와 Redis SDK 타입은 이 port에 들어가지 않는다. 실제 product의
|
||||||
|
use case는 `CacheRegionPort`를 상속한 semantic subtype을 정의해야 한다.
|
||||||
|
|
||||||
논리 캐시 이름 → 백엔드는 `app.cache.bindings.<name>=<backendId>` 로 선택하며, `backendId` 는
|
## Physical key
|
||||||
`CacheBackend#backendId()` 에서 온다. 백엔드는 `@ConditionalOnProperty` 게이팅 config(예:
|
|
||||||
`RedisCacheAdapterConfig`)가 `CacheBackend` 빈으로 기여한다.
|
`RedisKeyBuilder`만 다음 canonical shape를 만든다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
ca:<app>:<env>:<capability>:<region>:hv<hashVersion>:kv<keyVersion>:{<slot>}:<digest>:<kind>
|
||||||
|
```
|
||||||
|
|
||||||
|
민감한 사용자/tenant/composite 값은 raw key에 넣지 않는다. length-prefixed canonical bytes를
|
||||||
|
HMAC-SHA-256으로 digest한다. random opaque identifier는 SHA-256을 사용할 수 있다. builder는 slug,
|
||||||
|
version, 정확히 하나인 hash tag와 전체 UTF-8 byte bound를 검증한다.
|
||||||
|
|
||||||
|
## Atomic program foundation
|
||||||
|
|
||||||
|
`redis/program-set.json`은 세 Lua resource의 exact digest, signature, status, complexity와 timeout
|
||||||
|
certainty를 기록한다. `RedisAtomicPrimitives`는 compare-delete, compare-expire,
|
||||||
|
set-if-absent-with-TTL을 typed result로 노출하고 unknown status를 compatibility failure로
|
||||||
|
처리한다. owner/value/operation/TTL은 Redis 호출 전에 제한된다.
|
||||||
|
Generic descriptor/catalog/executor와 typed primitive facade는 package-private collaborator다.
|
||||||
|
Spring composition에는 raw Redis key/value/TTL을 받는 bean을 노출하지 않으며, 이후 semantic
|
||||||
|
port adapter가 내부에서만 이 facade를 사용한다.
|
||||||
|
따라서 이 program set은 현재 internal R0 foundation이며, 실제 도메인 capability가 바로 소비할
|
||||||
|
수 있는 production bean이나 application port가 아니다.
|
||||||
|
|
||||||
|
`RedisLuaProgramExecutor`가 catalog source로 SHA-1 script identity를 계산하여 `EVALSHA`를 먼저
|
||||||
|
호출하고 정확히 `NOSCRIPT`일 때만 compiled script를 `EVAL`한다. signature/argument bounds는
|
||||||
|
client 호출 전에 다시 검증하고 descriptor catalog membership 및 반환 status membership을
|
||||||
|
확인한다. unit lane은 강제 `NOSCRIPT` fallback을 검증하고 standalone real-service lane은
|
||||||
|
compare-and-delete의 실제 atomic execution을 검증한다.
|
||||||
|
|
||||||
|
## Managed runtime과 semantic region
|
||||||
|
|
||||||
|
`app.cache.redis.enabled=true`이고 `client-mode=managed`(기본값)이면 `LettuceRedisRuntime`이
|
||||||
|
단일 binary connection을 생성하고 종료 시 connection/client를 닫는다. 프로젝트가
|
||||||
|
`RedisClient`를 직접 제공하는 경우에는 `client-mode=external`을 명시해야 한다. 이 선택을
|
||||||
|
명시함으로써 Spring configuration 처리 순서에 따라 managed/custom client 선택이 달라지지 않는다.
|
||||||
|
Managed runtime은 reconnect 시 pending command를 replay하지 않고, disconnected command를
|
||||||
|
pre-send 거부하며, request queue와 동시 outstanding command를 같은 finite bound로 제한한다.
|
||||||
|
`RedisStringCacheRegion`은 `CacheRegionPort<String,String>` bean으로 제공되며 다음 결과를
|
||||||
|
구분한다.
|
||||||
|
|
||||||
|
- positive hit, authoritative negative hit, normal miss;
|
||||||
|
- unknown/corrupt/retired envelope와 fail-fast future envelope;
|
||||||
|
- read unavailable/overloaded와 mutation not-applied/indeterminate;
|
||||||
|
- invalidated와 already absent.
|
||||||
|
|
||||||
|
opaque source revision에는 대소 비교 의미가 없으므로
|
||||||
|
`ONLY_IF_SOURCE_REVISION_NEWER`는 임의 lexical comparison을 하지 않고
|
||||||
|
`NOT_RECORDED_PROVIDER_POLICY`를 반환한다.
|
||||||
|
|
||||||
|
Envelope는 source revision의 application invariant(1..128 characters)를 decode 때도 다시
|
||||||
|
검사하고 canonical bytes의 SHA-256 digest가 맞지 않으면 corrupt schema result로 격리한다.
|
||||||
|
|
||||||
|
추가 runtime setting은 `app.cache.redis.maximum-queued-commands=8`(범위 `1..4096`)과
|
||||||
|
`app.cache.redis.maximum-in-flight-bytes=16777216`이다. command count와 retained
|
||||||
|
request/response byte budget을 모두 통과해야 Lettuce 호출을 시작하며,
|
||||||
|
`queue-count × (maximum-value-bytes + overhead)`도 byte bound 이하여야 한다. 이 관계는
|
||||||
|
timeout 완료 뒤 driver가 응답 decode 전까지 command args를 유지하는 경우도 유한하게 제한한다.
|
||||||
|
timeout 직후에는 runtime admission population과 Lettuce retained population이 겹칠 수 있으므로
|
||||||
|
최악 상한은 대략 `maximum-in-flight-bytes + queue-count × per-command-bound`이고, 설정 검증은
|
||||||
|
두 번째 항이 첫 번째 항을 넘지 않게 해 최대 약 2배 population으로 제한한다.
|
||||||
|
|
||||||
|
read는 raw `GET`을 사용하지 않는다. 고정 Lua read가 `GETRANGE(0, maximum-envelope-bytes)`로
|
||||||
|
Redis가 wire에 내보내는 bulk reply 자체를 `maximum-envelope-bytes + 1` 이하로 자르고, 초과하면
|
||||||
|
작은 오류 응답으로 바꾼다. 따라서 다른 writer가 같은 물리 키를 오염시켜도 전체 대용량 value를
|
||||||
|
Netty/codec에 먼저 할당하지 않는다. managed runtime을 활성화할 때 host가 누락되면
|
||||||
|
`localhost`로 암묵 fallback하지 않고 startup을 실패시킨다.
|
||||||
|
|
||||||
|
## Legacy path
|
||||||
|
|
||||||
|
기존 `CacheStoreRouter`, `RedisCacheStore`, `FailOpenCacheStore`는 호환성을 위해 남아 있다. 이
|
||||||
|
경로는 `Optional.empty()`로 miss와 backend failure를 합친다. managed runtime을 사용할 때
|
||||||
|
legacy `put`에도 positive TTL을 적용하지만, 사용자 제공 legacy client의 TTL은 보장할 수 없으므로
|
||||||
|
새 semantic cache port 구현의 기준으로 사용하지 않는다.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain
|
||||||
|
./gradlew :adapter:outbound:cache-redis:redisServiceTest \
|
||||||
|
-Dredis.test.host=127.0.0.1 -Dredis.test.port=6379 --console=plain
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,14 +1,34 @@
|
|||||||
plugins { id 'groovy' }
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':domain-core')
|
|
||||||
implementation project(':application-core')
|
implementation project(':application-core')
|
||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
implementation project(':adapter:outbound:support')
|
implementation project(':adapter:outbound:support')
|
||||||
|
|
||||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||||
|
implementation 'io.lettuce:lettuce-core'
|
||||||
implementation 'org.slf4j:slf4j-api'
|
implementation 'org.slf4j:slf4j-api'
|
||||||
|
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
|
||||||
}
|
}
|
||||||
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
|
|
||||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||||
|
|
||||||
|
tasks.named('test') {
|
||||||
|
useJUnitPlatform {
|
||||||
|
excludeTags 'redis-service'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('redisServiceTest', Test) {
|
||||||
|
group = 'verification'
|
||||||
|
description = 'Runs the explicit real Redis standalone qualification lane.'
|
||||||
|
testClassesDirs = sourceSets.test.output.classesDirs
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
useJUnitPlatform {
|
||||||
|
includeTags 'redis-service'
|
||||||
|
}
|
||||||
|
['redis.test.host', 'redis.test.port'].each { propertyName ->
|
||||||
|
String propertyValue = System.getProperty(propertyName)
|
||||||
|
if (propertyValue != null) {
|
||||||
|
systemProperty propertyName, propertyValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shouldRunAfter tasks.named('test')
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# Manual edits can break the build and are not advised.
|
# Manual edits can break the build and are not advised.
|
||||||
# This file is expected to be part of source control.
|
# This file is expected to be part of source control.
|
||||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||||
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||||
ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||||
@@ -41,11 +41,21 @@ commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testComp
|
|||||||
info.picocli:picocli:4.7.7=checkstyle
|
info.picocli:picocli:4.7.7=checkstyle
|
||||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
io.netty:netty-buffer:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
io.netty:netty-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
io.netty:netty-handler:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
io.netty:netty-resolver:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
io.netty:netty-transport:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||||
jaxen:jaxen:2.0.0=spotbugs
|
jaxen:jaxen:2.0.0=spotbugs
|
||||||
@@ -59,13 +69,11 @@ org.apache.bcel:bcel:6.12.0=spotbugs
|
|||||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||||
org.apache.commons:commons-text:1.3=checkstyle
|
org.apache.commons:commons-text:1.3=checkstyle
|
||||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
|
||||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
|
||||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||||
org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
|
||||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||||
@@ -90,7 +98,7 @@ org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
|||||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit:junit-bom:6.1.0=spotbugs
|
org.junit:junit-bom:6.1.0=spotbugs
|
||||||
@@ -109,14 +117,14 @@ org.ow2.asm:asm-util:9.10.1=spotbugs
|
|||||||
org.ow2.asm:asm:9.10.1=spotbugs
|
org.ow2.asm:asm:9.10.1=spotbugs
|
||||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||||
|
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.reflections:reflections:0.10.2=checkstyle
|
org.reflections:reflections:0.10.2=checkstyle
|
||||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||||
org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
|
||||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||||
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
|
||||||
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
|
||||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
@@ -125,13 +133,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t
|
|||||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||||
@@ -149,7 +157,8 @@ org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
|||||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||||
org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
|
||||||
|
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||||
|
|||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import io.lettuce.core.ClientOptions;
|
||||||
|
import io.lettuce.core.RedisCommandExecutionException;
|
||||||
|
import io.lettuce.core.RedisCommandInterruptedException;
|
||||||
|
import io.lettuce.core.RedisCommandTimeoutException;
|
||||||
|
import io.lettuce.core.RedisConnectionException;
|
||||||
|
import io.lettuce.core.RedisConnectionStateListener;
|
||||||
|
import io.lettuce.core.RedisException;
|
||||||
|
import io.lettuce.core.RedisURI;
|
||||||
|
import io.lettuce.core.ScriptOutputType;
|
||||||
|
import io.lettuce.core.SetArgs;
|
||||||
|
import io.lettuce.core.TimeoutOptions;
|
||||||
|
import io.lettuce.core.api.StatefulRedisConnection;
|
||||||
|
import io.lettuce.core.api.sync.RedisCommands;
|
||||||
|
import io.lettuce.core.codec.ByteArrayCodec;
|
||||||
|
import java.net.SocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/** Managed standalone Lettuce connection shared by cache and typed Lua facilities. */
|
||||||
|
final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, AutoCloseable {
|
||||||
|
|
||||||
|
private static final String VALUE_TOO_LARGE_ERROR = "CA_VALUE_TOO_LARGE";
|
||||||
|
private static final byte[] BOUNDED_GET_SCRIPT =
|
||||||
|
"""
|
||||||
|
local limit = tonumber(ARGV[1])
|
||||||
|
local value = redis.call('GETRANGE', KEYS[1], 0, limit)
|
||||||
|
if #value > limit then
|
||||||
|
return redis.error_reply('CA_VALUE_TOO_LARGE')
|
||||||
|
end
|
||||||
|
if #value == 0 and redis.call('EXISTS', KEYS[1]) == 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
"""
|
||||||
|
.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
private final io.lettuce.core.RedisClient client;
|
||||||
|
private final StatefulRedisConnection<byte[], byte[]> connection;
|
||||||
|
private final RedisCommands<byte[], byte[]> commands;
|
||||||
|
private final Duration legacyTtl;
|
||||||
|
private final Duration shutdownTimeout;
|
||||||
|
private final AtomicBoolean connected = new AtomicBoolean(true);
|
||||||
|
private final RedisCommandAdmission commandAdmission;
|
||||||
|
private final int maximumReadableValueBytes;
|
||||||
|
private final int maximumCommandBytes;
|
||||||
|
private final AtomicBoolean closed = new AtomicBoolean();
|
||||||
|
|
||||||
|
private LettuceRedisRuntime(
|
||||||
|
io.lettuce.core.RedisClient client,
|
||||||
|
StatefulRedisConnection<byte[], byte[]> connection,
|
||||||
|
RedisRuntimeSettings settings) {
|
||||||
|
this.client = client;
|
||||||
|
this.connection = connection;
|
||||||
|
this.commands = connection.sync();
|
||||||
|
this.legacyTtl = settings.positiveTtl();
|
||||||
|
this.shutdownTimeout = settings.commandTimeout();
|
||||||
|
this.commandAdmission =
|
||||||
|
new RedisCommandAdmission(
|
||||||
|
settings.maximumQueuedCommands(), settings.maximumInFlightBytes());
|
||||||
|
this.maximumReadableValueBytes = settings.maximumValueBytes() + 1024 + 32;
|
||||||
|
this.maximumCommandBytes = settings.maximumValueBytes() + 2048;
|
||||||
|
connection.addListener(
|
||||||
|
new RedisConnectionStateListener() {
|
||||||
|
@Override
|
||||||
|
public void onRedisConnected(
|
||||||
|
io.lettuce.core.RedisChannelHandler<?, ?> connection, SocketAddress remoteAddress) {
|
||||||
|
connected.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRedisDisconnected(io.lettuce.core.RedisChannelHandler<?, ?> connection) {
|
||||||
|
connected.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static LettuceRedisRuntime connect(RedisRuntimeSettings settings) {
|
||||||
|
RedisURI uri = redisUri(settings);
|
||||||
|
io.lettuce.core.RedisClient client = io.lettuce.core.RedisClient.create(uri);
|
||||||
|
client.setOptions(clientOptions(settings));
|
||||||
|
try {
|
||||||
|
StatefulRedisConnection<byte[], byte[]> connection =
|
||||||
|
client.connect(ByteArrayCodec.INSTANCE, uri);
|
||||||
|
return new LettuceRedisRuntime(client, connection, settings);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
client.shutdown(Duration.ZERO, settings.commandTimeout());
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static RedisURI redisUri(RedisRuntimeSettings settings) {
|
||||||
|
RedisURI.Builder builder =
|
||||||
|
RedisURI.Builder.redis(settings.host(), settings.port())
|
||||||
|
.withTimeout(settings.commandTimeout());
|
||||||
|
if (!settings.password().isBlank()) {
|
||||||
|
builder.withPassword(settings.password().toCharArray());
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ClientOptions clientOptions(RedisRuntimeSettings settings) {
|
||||||
|
return ClientOptions.builder()
|
||||||
|
.autoReconnect(true)
|
||||||
|
.replayFilter(ignored -> true)
|
||||||
|
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
|
||||||
|
.requestQueueSize(settings.maximumQueuedCommands())
|
||||||
|
.timeoutOptions(TimeoutOptions.enabled(settings.commandTimeout()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<String> read(String key) {
|
||||||
|
byte[] value = get(key.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return value == null
|
||||||
|
? Optional.empty()
|
||||||
|
: Optional.of(new String(value, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(String key, String value) {
|
||||||
|
set(key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8), legacyTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] get(byte[] key) {
|
||||||
|
byte[] limit = Integer.toString(maximumReadableValueBytes).getBytes(StandardCharsets.US_ASCII);
|
||||||
|
try {
|
||||||
|
byte[] value =
|
||||||
|
execute(
|
||||||
|
false,
|
||||||
|
reservationBytes(
|
||||||
|
maximumReadableValueBytes,
|
||||||
|
List.of(BOUNDED_GET_SCRIPT),
|
||||||
|
List.of(key),
|
||||||
|
List.of(limit)),
|
||||||
|
() ->
|
||||||
|
commands.eval(
|
||||||
|
BOUNDED_GET_SCRIPT,
|
||||||
|
ScriptOutputType.VALUE,
|
||||||
|
new byte[][] {key.clone()},
|
||||||
|
limit));
|
||||||
|
return value == null ? null : value.clone();
|
||||||
|
} catch (RedisCommandExecutionException exception) {
|
||||||
|
if (exception.getMessage() != null
|
||||||
|
&& exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) {
|
||||||
|
throw new RedisValueTooLargeException();
|
||||||
|
}
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(byte[] key, byte[] value, Duration timeToLive) {
|
||||||
|
String result =
|
||||||
|
execute(
|
||||||
|
true,
|
||||||
|
reservationBytes(64, List.of(key, value)),
|
||||||
|
() ->
|
||||||
|
commands.set(
|
||||||
|
key.clone(), value.clone(), SetArgs.Builder.px(timeToLive.toMillis())));
|
||||||
|
if (!"OK".equals(result)) {
|
||||||
|
throw new IllegalStateException("Redis SET did not acknowledge the mutation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long delete(byte[] key) {
|
||||||
|
return execute(true, reservationBytes(32, List.of(key)), () -> commands.del(key.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments) {
|
||||||
|
try {
|
||||||
|
return execute(
|
||||||
|
true,
|
||||||
|
reservationBytes(256, keys, arguments),
|
||||||
|
() ->
|
||||||
|
commands.evalsha(
|
||||||
|
sha1,
|
||||||
|
ScriptOutputType.VALUE,
|
||||||
|
keys.toArray(byte[][]::new),
|
||||||
|
arguments.toArray(byte[][]::new)));
|
||||||
|
} catch (io.lettuce.core.RedisNoScriptException exception) {
|
||||||
|
throw new RedisNoScriptException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments) {
|
||||||
|
return execute(
|
||||||
|
true,
|
||||||
|
reservationBytes(256, List.of(script), keys, arguments),
|
||||||
|
() ->
|
||||||
|
commands.eval(
|
||||||
|
script.clone(),
|
||||||
|
ScriptOutputType.VALUE,
|
||||||
|
keys.toArray(byte[][]::new),
|
||||||
|
arguments.toArray(byte[][]::new)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (!closed.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.close();
|
||||||
|
} finally {
|
||||||
|
client.shutdown(Duration.ZERO, shutdownTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureOpen() {
|
||||||
|
if (closed.get()) {
|
||||||
|
throw new IllegalStateException("Redis runtime is closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T execute(boolean mutation, int reservationBytes, Supplier<T> command) {
|
||||||
|
ensureOpen();
|
||||||
|
if (reservationBytes > maximumCommandBytes) {
|
||||||
|
throw new RedisCommandFailureException(
|
||||||
|
RedisCommandFailureException.Kind.OVERLOADED,
|
||||||
|
RedisCommandFailureException.Certainty.NOT_APPLIED,
|
||||||
|
"Redis command exceeds the retained-byte bound",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
if (!connected.get()) {
|
||||||
|
throw new RedisCommandFailureException(
|
||||||
|
RedisCommandFailureException.Kind.UNAVAILABLE,
|
||||||
|
RedisCommandFailureException.Certainty.NOT_APPLIED,
|
||||||
|
"Redis command rejected while disconnected",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
RedisCommandAdmission.Lease admission = commandAdmission.tryAcquire(reservationBytes);
|
||||||
|
if (admission == null) {
|
||||||
|
throw new RedisCommandFailureException(
|
||||||
|
RedisCommandFailureException.Kind.OVERLOADED,
|
||||||
|
RedisCommandFailureException.Certainty.NOT_APPLIED,
|
||||||
|
"Redis command count or byte admission is saturated",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
try (admission) {
|
||||||
|
return command.get();
|
||||||
|
} catch (RedisCommandExecutionException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (RedisCommandInterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw exception;
|
||||||
|
} catch (RedisCommandTimeoutException exception) {
|
||||||
|
throw commandFailure(mutation, "Redis command timed out", exception);
|
||||||
|
} catch (RedisConnectionException exception) {
|
||||||
|
throw commandFailure(mutation, "Redis connection failed during a command", exception);
|
||||||
|
} catch (RedisException exception) {
|
||||||
|
throw commandFailure(mutation, "Redis transport failed during a command", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RedisCommandFailureException commandFailure(
|
||||||
|
boolean mutation, String message, RuntimeException cause) {
|
||||||
|
return new RedisCommandFailureException(
|
||||||
|
RedisCommandFailureException.Kind.UNAVAILABLE,
|
||||||
|
mutation
|
||||||
|
? RedisCommandFailureException.Certainty.INDETERMINATE
|
||||||
|
: RedisCommandFailureException.Certainty.NOT_APPLIED,
|
||||||
|
message,
|
||||||
|
cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SafeVarargs
|
||||||
|
private static int reservationBytes(int responseBytes, List<byte[]>... groups) {
|
||||||
|
long total = Math.max(1, responseBytes);
|
||||||
|
for (List<byte[]> group : groups) {
|
||||||
|
for (byte[] value : group) {
|
||||||
|
if (value == null) {
|
||||||
|
return Integer.MAX_VALUE;
|
||||||
|
}
|
||||||
|
total += value.length;
|
||||||
|
if (total > Integer.MAX_VALUE) {
|
||||||
|
return Integer.MAX_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (int) total;
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Typed facade for bounded owner-safe and expirable Redis mutations. */
|
||||||
|
final class RedisAtomicPrimitives {
|
||||||
|
|
||||||
|
private static final int MAXIMUM_OWNER_BYTES = 128;
|
||||||
|
private static final int MAXIMUM_OPERATION_ID_BYTES = 128;
|
||||||
|
private static final int MAXIMUM_VALUE_BYTES = 1_048_576;
|
||||||
|
private static final long MAXIMUM_TTL_MILLIS = Duration.ofDays(30).toMillis();
|
||||||
|
|
||||||
|
private final RedisProgramCatalog catalog;
|
||||||
|
private final RedisProgramExecutor executor;
|
||||||
|
|
||||||
|
RedisAtomicPrimitives(RedisProgramCatalog catalog, RedisProgramExecutor executor) {
|
||||||
|
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
|
||||||
|
this.executor = Objects.requireNonNull(executor, "executor must be non-null");
|
||||||
|
}
|
||||||
|
|
||||||
|
CompareDeleteResult compareAndDelete(String key, byte[] expectedOwner) {
|
||||||
|
byte[] keyBytes = key(key);
|
||||||
|
byte[] owner = bounded(expectedOwner, MAXIMUM_OWNER_BYTES, "expected owner");
|
||||||
|
String status = execute(RedisProgramId.COMPARE_AND_DELETE, List.of(keyBytes), List.of(owner));
|
||||||
|
return parse(RedisProgramId.COMPARE_AND_DELETE, status, CompareDeleteResult.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
CompareExpireResult compareAndExpire(String key, byte[] expectedOwner, Duration timeToLive) {
|
||||||
|
byte[] keyBytes = key(key);
|
||||||
|
byte[] owner = bounded(expectedOwner, MAXIMUM_OWNER_BYTES, "expected owner");
|
||||||
|
byte[] ttl = ttl(timeToLive);
|
||||||
|
String status =
|
||||||
|
execute(RedisProgramId.COMPARE_AND_EXPIRE, List.of(keyBytes), List.of(owner, ttl));
|
||||||
|
return parse(RedisProgramId.COMPARE_AND_EXPIRE, status, CompareExpireResult.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
SetIfAbsentResult setIfAbsentWithTtl(
|
||||||
|
String key, byte[] value, Duration timeToLive, String operationId) {
|
||||||
|
byte[] keyBytes = key(key);
|
||||||
|
byte[] boundedValue = bounded(value, MAXIMUM_VALUE_BYTES, "value");
|
||||||
|
byte[] ttl = ttl(timeToLive);
|
||||||
|
byte[] operation =
|
||||||
|
bounded(
|
||||||
|
Objects.requireNonNull(operationId, "operationId must be non-null")
|
||||||
|
.getBytes(StandardCharsets.UTF_8),
|
||||||
|
MAXIMUM_OPERATION_ID_BYTES,
|
||||||
|
"operationId");
|
||||||
|
String status =
|
||||||
|
execute(
|
||||||
|
RedisProgramId.SET_IF_ABSENT_WITH_TTL,
|
||||||
|
List.of(keyBytes),
|
||||||
|
List.of(boundedValue, ttl, operation));
|
||||||
|
return parse(RedisProgramId.SET_IF_ABSENT_WITH_TTL, status, SetIfAbsentResult.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String execute(RedisProgramId id, List<byte[]> keys, List<byte[]> arguments) {
|
||||||
|
RedisProgramDescriptor descriptor = catalog.descriptor(id);
|
||||||
|
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
|
||||||
|
throw new IllegalStateException("typed Redis program signature drift for " + id.externalId());
|
||||||
|
}
|
||||||
|
return executor.execute(descriptor, List.copyOf(keys), List.copyOf(arguments));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] key(String key) {
|
||||||
|
Objects.requireNonNull(key, "key must be non-null");
|
||||||
|
return bounded(key.getBytes(StandardCharsets.UTF_8), 512, "key");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ttl(Duration timeToLive) {
|
||||||
|
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
|
||||||
|
long milliseconds;
|
||||||
|
try {
|
||||||
|
milliseconds = timeToLive.toMillis();
|
||||||
|
} catch (ArithmeticException exception) {
|
||||||
|
throw new IllegalArgumentException("TTL exceeds supported range", exception);
|
||||||
|
}
|
||||||
|
if (milliseconds < 1 || milliseconds > MAXIMUM_TTL_MILLIS) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"TTL must be between 1 and " + MAXIMUM_TTL_MILLIS + " milliseconds");
|
||||||
|
}
|
||||||
|
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] bounded(byte[] value, int maximumBytes, String field) {
|
||||||
|
Objects.requireNonNull(value, field + " must be non-null");
|
||||||
|
if (value.length < 1 || value.length > maximumBytes) {
|
||||||
|
throw new IllegalArgumentException(field + " must contain 1.." + maximumBytes + " bytes");
|
||||||
|
}
|
||||||
|
return value.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <E extends Enum<E>> E parse(
|
||||||
|
RedisProgramId id, String status, Class<E> resultType) {
|
||||||
|
try {
|
||||||
|
return Enum.valueOf(resultType, status);
|
||||||
|
} catch (IllegalArgumentException | NullPointerException exception) {
|
||||||
|
throw new RedisProgramCompatibilityException(id, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CompareDeleteResult {
|
||||||
|
DELETED,
|
||||||
|
ABSENT,
|
||||||
|
NOT_OWNER,
|
||||||
|
WRONG_TYPE,
|
||||||
|
INVALID
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CompareExpireResult {
|
||||||
|
RENEWED,
|
||||||
|
ABSENT,
|
||||||
|
NOT_OWNER,
|
||||||
|
WRONG_TYPE,
|
||||||
|
INVALID
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SetIfAbsentResult {
|
||||||
|
SET,
|
||||||
|
EXISTS,
|
||||||
|
WRONG_TYPE,
|
||||||
|
INVALID
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Minimal binary Redis command surface owned entirely by this adapter. */
|
||||||
|
interface RedisBinaryCommands {
|
||||||
|
|
||||||
|
byte[] get(byte[] key);
|
||||||
|
|
||||||
|
void set(byte[] key, byte[] value, Duration timeToLive);
|
||||||
|
|
||||||
|
long delete(byte[] key);
|
||||||
|
|
||||||
|
byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments);
|
||||||
|
|
||||||
|
byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments);
|
||||||
|
}
|
||||||
+52
-1
@@ -1,7 +1,11 @@
|
|||||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
|
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||||
|
import dev.caskeleton.application.cache.CacheRegionPort;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@@ -18,9 +22,56 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
* AdapterDisabledException}; binding to a disabled backend → startup failure). Backend configs
|
* AdapterDisabledException}; binding to a disabled backend → startup failure). Backend configs
|
||||||
* therefore never need to know about each other — a new backend is new files only.
|
* therefore never need to know about each other — a new backend is new files only.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@EnableConfigurationProperties(RedisRuntimeSettings.class)
|
||||||
public class RedisCacheAdapterConfig {
|
public class RedisCacheAdapterConfig {
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
name = "app.cache.redis.client-mode",
|
||||||
|
havingValue = "managed",
|
||||||
|
matchIfMissing = true)
|
||||||
|
static class ManagedRedisRuntimeConfig {
|
||||||
|
|
||||||
|
@Bean(destroyMethod = "close")
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
name = "app.cache.redis.enabled",
|
||||||
|
havingValue = "true",
|
||||||
|
matchIfMissing = false)
|
||||||
|
LettuceRedisRuntime lettuceRedisRuntime(RedisRuntimeSettings settings) {
|
||||||
|
settings.hmacSecret();
|
||||||
|
return LettuceRedisRuntime.connect(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnBean(LettuceRedisRuntime.class)
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
name = "app.cache.redis.enabled",
|
||||||
|
havingValue = "true",
|
||||||
|
matchIfMissing = false)
|
||||||
|
CacheRegionPort<String, String> redisStringCacheRegion(
|
||||||
|
LettuceRedisRuntime runtime, RedisRuntimeSettings settings) {
|
||||||
|
RedisKeyNamespace namespace =
|
||||||
|
new RedisKeyNamespace(
|
||||||
|
settings.namespaceApplication(),
|
||||||
|
settings.namespaceEnvironment(),
|
||||||
|
"cache",
|
||||||
|
settings.semanticRegion(),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
"entry",
|
||||||
|
512);
|
||||||
|
return new RedisStringCacheRegion(
|
||||||
|
new RedisCacheRegionPolicy(
|
||||||
|
namespace,
|
||||||
|
settings.hmacSecret(),
|
||||||
|
settings.positiveTtl(),
|
||||||
|
settings.negativeTtl(),
|
||||||
|
settings.maximumValueBytes()),
|
||||||
|
runtime);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnProperty(
|
@ConditionalOnProperty(
|
||||||
name = "app.cache.redis.enabled",
|
name = "app.cache.redis.enabled",
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.cache.AuthoritativeAbsence;
|
||||||
|
import dev.caskeleton.application.cache.CacheLookup;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.CharacterCodingException;
|
||||||
|
import java.nio.charset.CodingErrorAction;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Strict versioned binary envelope for positive and authoritative-negative cache entries. */
|
||||||
|
final class RedisCacheEnvelopeCodec {
|
||||||
|
|
||||||
|
private static final int MAGIC = 0x43414348;
|
||||||
|
private static final byte VERSION = 1;
|
||||||
|
private static final byte POSITIVE = 1;
|
||||||
|
private static final byte NEGATIVE = 2;
|
||||||
|
private static final int CONTENT_HEADER_BYTES =
|
||||||
|
Integer.BYTES + Byte.BYTES + Byte.BYTES + Short.BYTES + Integer.BYTES;
|
||||||
|
private static final int DIGEST_BYTES = 32;
|
||||||
|
|
||||||
|
private RedisCacheEnvelopeCodec() {}
|
||||||
|
|
||||||
|
static byte[] positive(String value, String sourceRevision, int maximumValueBytes) {
|
||||||
|
return encode(
|
||||||
|
POSITIVE,
|
||||||
|
utf8(Objects.requireNonNull(value, "value must be non-null")),
|
||||||
|
sourceRevision,
|
||||||
|
maximumValueBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] negative(
|
||||||
|
AuthoritativeAbsence reason, String sourceRevision, int maximumValueBytes) {
|
||||||
|
Objects.requireNonNull(reason, "reason must be non-null");
|
||||||
|
return encode(NEGATIVE, utf8(reason.name()), sourceRevision, maximumValueBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Decoded decode(byte[] envelope, int maximumValueBytes) {
|
||||||
|
if (envelope == null
|
||||||
|
|| envelope.length < CONTENT_HEADER_BYTES + DIGEST_BYTES
|
||||||
|
|| envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) {
|
||||||
|
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, envelope.length - DIGEST_BYTES);
|
||||||
|
if (buffer.getInt() != MAGIC) {
|
||||||
|
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||||
|
}
|
||||||
|
byte version = buffer.get();
|
||||||
|
if (version > VERSION) {
|
||||||
|
return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION);
|
||||||
|
}
|
||||||
|
if (version < VERSION) {
|
||||||
|
return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION);
|
||||||
|
}
|
||||||
|
byte[] expectedDigest = sha256(Arrays.copyOf(envelope, envelope.length - DIGEST_BYTES));
|
||||||
|
byte[] actualDigest =
|
||||||
|
Arrays.copyOfRange(envelope, envelope.length - DIGEST_BYTES, envelope.length);
|
||||||
|
if (!MessageDigest.isEqual(expectedDigest, actualDigest)) {
|
||||||
|
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
|
||||||
|
}
|
||||||
|
byte type = buffer.get();
|
||||||
|
int revisionSize = Short.toUnsignedInt(buffer.getShort());
|
||||||
|
int payloadSize = buffer.getInt();
|
||||||
|
if (revisionSize < 1
|
||||||
|
|| revisionSize > 512
|
||||||
|
|| payloadSize < 1
|
||||||
|
|| payloadSize > maximumValueBytes
|
||||||
|
|| buffer.remaining() != revisionSize + payloadSize) {
|
||||||
|
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||||
|
}
|
||||||
|
byte[] revision = new byte[revisionSize];
|
||||||
|
byte[] payload = new byte[payloadSize];
|
||||||
|
buffer.get(revision);
|
||||||
|
buffer.get(payload);
|
||||||
|
String sourceRevision = strictUtf8(revision);
|
||||||
|
if (!validSourceRevision(sourceRevision)) {
|
||||||
|
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||||
|
}
|
||||||
|
if (type == POSITIVE) {
|
||||||
|
return new Positive(strictUtf8(payload), sourceRevision);
|
||||||
|
}
|
||||||
|
if (type == NEGATIVE) {
|
||||||
|
return new Negative(AuthoritativeAbsence.valueOf(strictUtf8(payload)));
|
||||||
|
}
|
||||||
|
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||||
|
} catch (IllegalArgumentException | CharacterCodingException exception) {
|
||||||
|
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] encode(
|
||||||
|
byte type, byte[] payload, String sourceRevision, int maximumValueBytes) {
|
||||||
|
byte[] revision =
|
||||||
|
utf8(Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null"));
|
||||||
|
if (!validSourceRevision(sourceRevision) || revision.length > 512) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"sourceRevision must contain 1..128 characters and at most 512 UTF-8 bytes");
|
||||||
|
}
|
||||||
|
if (payload.length < 1 || payload.length > maximumValueBytes) {
|
||||||
|
throw new IllegalArgumentException("cache payload exceeds configured maximum bytes");
|
||||||
|
}
|
||||||
|
byte[] content =
|
||||||
|
ByteBuffer.allocate(CONTENT_HEADER_BYTES + revision.length + payload.length)
|
||||||
|
.putInt(MAGIC)
|
||||||
|
.put(VERSION)
|
||||||
|
.put(type)
|
||||||
|
.putShort((short) revision.length)
|
||||||
|
.putInt(payload.length)
|
||||||
|
.put(revision)
|
||||||
|
.put(payload)
|
||||||
|
.array();
|
||||||
|
return ByteBuffer.allocate(content.length + DIGEST_BYTES)
|
||||||
|
.put(content)
|
||||||
|
.put(sha256(content))
|
||||||
|
.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] utf8(String value) {
|
||||||
|
return value.getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String strictUtf8(byte[] value) throws CharacterCodingException {
|
||||||
|
return StandardCharsets.UTF_8
|
||||||
|
.newDecoder()
|
||||||
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
|
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||||
|
.decode(ByteBuffer.wrap(value))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean validSourceRevision(String sourceRevision) {
|
||||||
|
return !sourceRevision.isBlank() && sourceRevision.length() <= 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] sha256(byte[] content) {
|
||||||
|
try {
|
||||||
|
return MessageDigest.getInstance("SHA-256").digest(content);
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 unavailable for cache envelope", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Incompatible incompatible(CacheLookup.SchemaCategory category) {
|
||||||
|
return new Incompatible(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Decoded permits Positive, Negative, Incompatible {}
|
||||||
|
|
||||||
|
record Positive(String value, String sourceRevision) implements Decoded {}
|
||||||
|
|
||||||
|
record Negative(AuthoritativeAbsence reason) implements Decoded {}
|
||||||
|
|
||||||
|
record Incompatible(CacheLookup.SchemaCategory category) implements Decoded {}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Immutable key, TTL and envelope bounds for one semantic string cache region. */
|
||||||
|
final class RedisCacheRegionPolicy {
|
||||||
|
|
||||||
|
private final RedisKeyNamespace namespace;
|
||||||
|
private final byte[] hmacSecret;
|
||||||
|
private final Duration positiveTtl;
|
||||||
|
private final Duration negativeTtl;
|
||||||
|
private final int maximumValueBytes;
|
||||||
|
|
||||||
|
RedisCacheRegionPolicy(
|
||||||
|
RedisKeyNamespace namespace,
|
||||||
|
byte[] hmacSecret,
|
||||||
|
Duration positiveTtl,
|
||||||
|
Duration negativeTtl,
|
||||||
|
int maximumValueBytes) {
|
||||||
|
this.namespace = Objects.requireNonNull(namespace, "namespace must be non-null");
|
||||||
|
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null");
|
||||||
|
if (hmacSecret.length < 32) {
|
||||||
|
throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes");
|
||||||
|
}
|
||||||
|
this.hmacSecret = hmacSecret.clone();
|
||||||
|
this.positiveTtl = positive(positiveTtl, "positiveTtl");
|
||||||
|
this.negativeTtl = positive(negativeTtl, "negativeTtl");
|
||||||
|
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
|
||||||
|
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
|
||||||
|
}
|
||||||
|
this.maximumValueBytes = maximumValueBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
RedisKeyNamespace namespace() {
|
||||||
|
return namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] hmacSecret() {
|
||||||
|
return hmacSecret.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration positiveTtl() {
|
||||||
|
return positiveTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration negativeTtl() {
|
||||||
|
return negativeTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
int maximumValueBytes() {
|
||||||
|
return maximumValueBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Duration positive(Duration value, String field) {
|
||||||
|
Objects.requireNonNull(value, field + " must be non-null");
|
||||||
|
if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(30)) > 0) {
|
||||||
|
throw new IllegalArgumentException(field + " must be positive and at most 30 days");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import java.util.concurrent.Semaphore;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/** Immediate dual count/byte admission for commands retained by the managed connection. */
|
||||||
|
final class RedisCommandAdmission {
|
||||||
|
|
||||||
|
private final Semaphore commands;
|
||||||
|
private final Semaphore bytes;
|
||||||
|
|
||||||
|
RedisCommandAdmission(int maximumCommands, int maximumBytes) {
|
||||||
|
commands = new Semaphore(maximumCommands);
|
||||||
|
bytes = new Semaphore(maximumBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Lease tryAcquire(int reservationBytes) {
|
||||||
|
if (reservationBytes < 1 || !commands.tryAcquire()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!bytes.tryAcquire(reservationBytes)) {
|
||||||
|
commands.release();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Lease(reservationBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Lease implements AutoCloseable {
|
||||||
|
|
||||||
|
private final int reservationBytes;
|
||||||
|
private final AtomicBoolean closed = new AtomicBoolean();
|
||||||
|
|
||||||
|
private Lease(int reservationBytes) {
|
||||||
|
this.reservationBytes = reservationBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (closed.compareAndSet(false, true)) {
|
||||||
|
bytes.release(reservationBytes);
|
||||||
|
commands.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
/** Adapter-internal transport failure with explicit overload and mutation certainty. */
|
||||||
|
final class RedisCommandFailureException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Kind kind;
|
||||||
|
private final Certainty certainty;
|
||||||
|
|
||||||
|
RedisCommandFailureException(Kind kind, Certainty certainty, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.kind = kind;
|
||||||
|
this.certainty = certainty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Kind kind() {
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
Certainty certainty() {
|
||||||
|
return certainty;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Kind {
|
||||||
|
UNAVAILABLE,
|
||||||
|
OVERLOADED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Certainty {
|
||||||
|
NOT_APPLIED,
|
||||||
|
INDETERMINATE
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Executes an exact catalog script through EVALSHA, with EVAL allowed only after NOSCRIPT. */
|
||||||
|
final class RedisLuaProgramExecutor implements RedisProgramExecutor {
|
||||||
|
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
|
private final RedisProgramCatalog catalog;
|
||||||
|
private final RedisBinaryCommands commands;
|
||||||
|
|
||||||
|
RedisLuaProgramExecutor(RedisProgramCatalog catalog, RedisBinaryCommands commands) {
|
||||||
|
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
|
||||||
|
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(
|
||||||
|
RedisProgramDescriptor descriptor, List<byte[]> keys, List<byte[]> arguments) {
|
||||||
|
Objects.requireNonNull(descriptor, "descriptor must be non-null");
|
||||||
|
if (catalog.descriptor(descriptor.id()) != descriptor) {
|
||||||
|
throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog");
|
||||||
|
}
|
||||||
|
validate(descriptor, keys, arguments);
|
||||||
|
byte[] result;
|
||||||
|
try {
|
||||||
|
result = commands.evalSha(sha1(descriptor.scriptBytes()), keys, arguments);
|
||||||
|
} catch (RedisNoScriptException noScript) {
|
||||||
|
result = commands.eval(descriptor.scriptBytes(), keys, arguments);
|
||||||
|
}
|
||||||
|
if (result == null || result.length == 0 || result.length > 128) {
|
||||||
|
throw new IllegalStateException("Redis program returned an invalid status payload");
|
||||||
|
}
|
||||||
|
String status = new String(result, StandardCharsets.US_ASCII);
|
||||||
|
if (!descriptor.statuses().contains(status)) {
|
||||||
|
throw new RedisProgramCompatibilityException(descriptor.id(), status);
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validate(
|
||||||
|
RedisProgramDescriptor descriptor, List<byte[]> keys, List<byte[]> arguments) {
|
||||||
|
Objects.requireNonNull(keys, "keys must be non-null");
|
||||||
|
Objects.requireNonNull(arguments, "arguments must be non-null");
|
||||||
|
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
|
||||||
|
throw new IllegalArgumentException("Redis program signature does not match descriptor");
|
||||||
|
}
|
||||||
|
for (byte[] key : keys) {
|
||||||
|
bounded(key, descriptor.maximumKeyBytes(), "key");
|
||||||
|
}
|
||||||
|
for (byte[] argument : arguments) {
|
||||||
|
bounded(argument, descriptor.maximumArgumentBytes(), "argument");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void bounded(byte[] value, int maximumBytes, String field) {
|
||||||
|
if (value == null || value.length < 1 || value.length > maximumBytes) {
|
||||||
|
throw new IllegalArgumentException("Redis program " + field + " is out of bounds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sha1(byte[] script) {
|
||||||
|
try {
|
||||||
|
return HEX.formatHex(MessageDigest.getInstance("SHA-1").digest(script));
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("SHA-1 unavailable for Redis script identity", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
/** Internal signal used only to authorize the bounded EVAL fallback. */
|
||||||
|
final class RedisNoScriptException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Closed catalog that binds typed program IDs to immutable versioned Lua resources. */
|
||||||
|
final class RedisProgramCatalog {
|
||||||
|
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
|
private final Map<RedisProgramId, RedisProgramDescriptor> descriptors;
|
||||||
|
|
||||||
|
private RedisProgramCatalog(Map<RedisProgramId, RedisProgramDescriptor> descriptors) {
|
||||||
|
this.descriptors = Map.copyOf(descriptors);
|
||||||
|
}
|
||||||
|
|
||||||
|
static RedisProgramCatalog foundation() {
|
||||||
|
Map<RedisProgramId, RedisProgramDescriptor> descriptors = new EnumMap<>(RedisProgramId.class);
|
||||||
|
descriptors.put(
|
||||||
|
RedisProgramId.COMPARE_AND_DELETE,
|
||||||
|
descriptor(
|
||||||
|
RedisProgramId.COMPARE_AND_DELETE,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
512,
|
||||||
|
128,
|
||||||
|
Set.of("DELETED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID")));
|
||||||
|
descriptors.put(
|
||||||
|
RedisProgramId.COMPARE_AND_EXPIRE,
|
||||||
|
descriptor(
|
||||||
|
RedisProgramId.COMPARE_AND_EXPIRE,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
512,
|
||||||
|
128,
|
||||||
|
Set.of("RENEWED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID")));
|
||||||
|
descriptors.put(
|
||||||
|
RedisProgramId.SET_IF_ABSENT_WITH_TTL,
|
||||||
|
descriptor(
|
||||||
|
RedisProgramId.SET_IF_ABSENT_WITH_TTL,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
512,
|
||||||
|
1_048_576,
|
||||||
|
Set.of("SET", "EXISTS", "WRONG_TYPE", "INVALID")));
|
||||||
|
return new RedisProgramCatalog(descriptors);
|
||||||
|
}
|
||||||
|
|
||||||
|
RedisProgramDescriptor descriptor(RedisProgramId id) {
|
||||||
|
RedisProgramDescriptor descriptor = descriptors.get(id);
|
||||||
|
if (descriptor == null) {
|
||||||
|
throw new IllegalArgumentException("unknown Redis program id: " + id);
|
||||||
|
}
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
Collection<RedisProgramDescriptor> descriptors() {
|
||||||
|
return descriptors.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RedisProgramDescriptor descriptor(
|
||||||
|
RedisProgramId id,
|
||||||
|
int keyCount,
|
||||||
|
int argumentCount,
|
||||||
|
int maximumKeyBytes,
|
||||||
|
int maximumArgumentBytes,
|
||||||
|
Set<String> statuses) {
|
||||||
|
byte[] script = readResource(id.scriptResource());
|
||||||
|
return new RedisProgramDescriptor(
|
||||||
|
id,
|
||||||
|
HEX.formatHex(sha256(script)),
|
||||||
|
script,
|
||||||
|
keyCount,
|
||||||
|
argumentCount,
|
||||||
|
maximumKeyBytes,
|
||||||
|
maximumArgumentBytes,
|
||||||
|
statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readResource(String resource) {
|
||||||
|
ClassLoader loader = RedisProgramCatalog.class.getClassLoader();
|
||||||
|
try (InputStream input = loader.getResourceAsStream(resource)) {
|
||||||
|
if (input == null) {
|
||||||
|
throw new IllegalStateException("missing Redis program resource: " + resource);
|
||||||
|
}
|
||||||
|
return input.readAllBytes();
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new IllegalStateException("cannot read Redis program resource: " + resource, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] sha256(byte[] input) {
|
||||||
|
try {
|
||||||
|
return MessageDigest.getInstance("SHA-256").digest(input);
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 unavailable", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
/** Raised when runtime program output is not part of the compiled program contract. */
|
||||||
|
public final class RedisProgramCompatibilityException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
RedisProgramCompatibilityException(RedisProgramId id, String status) {
|
||||||
|
super("Redis program " + id.externalId() + " returned unknown status: " + status);
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Immutable signature and exact source digest for one versioned atomic program. */
|
||||||
|
final class RedisProgramDescriptor {
|
||||||
|
|
||||||
|
private final RedisProgramId id;
|
||||||
|
private final String sha256;
|
||||||
|
private final byte[] scriptBytes;
|
||||||
|
private final int keyCount;
|
||||||
|
private final int argumentCount;
|
||||||
|
private final int maximumKeyBytes;
|
||||||
|
private final int maximumArgumentBytes;
|
||||||
|
private final Set<String> statuses;
|
||||||
|
|
||||||
|
RedisProgramDescriptor(
|
||||||
|
RedisProgramId id,
|
||||||
|
String sha256,
|
||||||
|
byte[] scriptBytes,
|
||||||
|
int keyCount,
|
||||||
|
int argumentCount,
|
||||||
|
int maximumKeyBytes,
|
||||||
|
int maximumArgumentBytes,
|
||||||
|
Set<String> statuses) {
|
||||||
|
this.id = Objects.requireNonNull(id, "id must be non-null");
|
||||||
|
if (sha256 == null || !sha256.matches("[0-9a-f]{64}")) {
|
||||||
|
throw new IllegalArgumentException("sha256 must be 64 lowercase hexadecimal characters");
|
||||||
|
}
|
||||||
|
this.sha256 = sha256;
|
||||||
|
Objects.requireNonNull(scriptBytes, "scriptBytes must be non-null");
|
||||||
|
if (scriptBytes.length == 0) {
|
||||||
|
throw new IllegalArgumentException("scriptBytes must be non-empty");
|
||||||
|
}
|
||||||
|
this.scriptBytes = scriptBytes.clone();
|
||||||
|
if (keyCount < 1 || argumentCount < 1) {
|
||||||
|
throw new IllegalArgumentException("program key and argument counts must be positive");
|
||||||
|
}
|
||||||
|
this.keyCount = keyCount;
|
||||||
|
this.argumentCount = argumentCount;
|
||||||
|
if (maximumKeyBytes < 1 || maximumArgumentBytes < 1) {
|
||||||
|
throw new IllegalArgumentException("program byte bounds must be positive");
|
||||||
|
}
|
||||||
|
this.maximumKeyBytes = maximumKeyBytes;
|
||||||
|
this.maximumArgumentBytes = maximumArgumentBytes;
|
||||||
|
this.statuses = Set.copyOf(statuses);
|
||||||
|
if (this.statuses.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("program statuses must be non-empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RedisProgramId id() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sha256() {
|
||||||
|
return sha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] scriptBytes() {
|
||||||
|
return scriptBytes.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
int keyCount() {
|
||||||
|
return keyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
int argumentCount() {
|
||||||
|
return argumentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
int maximumKeyBytes() {
|
||||||
|
return maximumKeyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
int maximumArgumentBytes() {
|
||||||
|
return maximumArgumentBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> statuses() {
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter-internal execution seam. Implementations may use Functions or EVALSHA, but application
|
||||||
|
* code must only depend on semantic ports and typed facades.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
interface RedisProgramExecutor {
|
||||||
|
|
||||||
|
String execute(RedisProgramDescriptor descriptor, List<byte[]> keys, List<byte[]> arguments);
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
/** Versioned Redis atomic programs available in the foundation catalog. */
|
||||||
|
enum RedisProgramId {
|
||||||
|
COMPARE_AND_DELETE("compare-and-delete-v1", "redis/scripts/compare-and-delete-v1.lua"),
|
||||||
|
COMPARE_AND_EXPIRE("compare-and-expire-v1", "redis/scripts/compare-and-expire-v1.lua"),
|
||||||
|
SET_IF_ABSENT_WITH_TTL(
|
||||||
|
"set-if-absent-with-ttl-v1", "redis/scripts/set-if-absent-with-ttl-v1.lua");
|
||||||
|
|
||||||
|
private final String externalId;
|
||||||
|
private final String scriptResource;
|
||||||
|
|
||||||
|
RedisProgramId(String externalId, String scriptResource) {
|
||||||
|
this.externalId = externalId;
|
||||||
|
this.scriptResource = scriptResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
String externalId() {
|
||||||
|
return externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
String scriptResource() {
|
||||||
|
return scriptResource;
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Base64;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.context.properties.bind.ConstructorBinding;
|
||||||
|
|
||||||
|
/** Typed standalone Redis runtime and semantic cache settings. */
|
||||||
|
@ConfigurationProperties(prefix = "app.cache.redis")
|
||||||
|
public record RedisRuntimeSettings(
|
||||||
|
boolean enabled,
|
||||||
|
ClientMode clientMode,
|
||||||
|
String host,
|
||||||
|
int port,
|
||||||
|
String password,
|
||||||
|
String keyHmacSecret,
|
||||||
|
Duration commandTimeout,
|
||||||
|
Duration positiveTtl,
|
||||||
|
Duration negativeTtl,
|
||||||
|
String namespaceApplication,
|
||||||
|
String namespaceEnvironment,
|
||||||
|
String semanticRegion,
|
||||||
|
int maximumValueBytes,
|
||||||
|
int maximumQueuedCommands,
|
||||||
|
int maximumInFlightBytes) {
|
||||||
|
|
||||||
|
private static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30);
|
||||||
|
private static final Duration MAXIMUM_TTL = Duration.ofDays(30);
|
||||||
|
|
||||||
|
@ConstructorBinding
|
||||||
|
public RedisRuntimeSettings {
|
||||||
|
clientMode = clientMode == null ? ClientMode.MANAGED : clientMode;
|
||||||
|
String configuredHost = host == null ? "" : host.trim();
|
||||||
|
if (enabled && clientMode == ClientMode.MANAGED && configuredHost.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Redis host must be configured when managed Redis is enabled");
|
||||||
|
}
|
||||||
|
host = configuredHost.isEmpty() ? "localhost" : configuredHost;
|
||||||
|
port = port == 0 ? 6379 : port;
|
||||||
|
password = password == null ? "" : password;
|
||||||
|
keyHmacSecret = keyHmacSecret == null ? "" : keyHmacSecret;
|
||||||
|
commandTimeout = commandTimeout == null ? Duration.ofSeconds(2) : commandTimeout;
|
||||||
|
positiveTtl = positiveTtl == null ? Duration.ofMinutes(5) : positiveTtl;
|
||||||
|
negativeTtl = negativeTtl == null ? Duration.ofSeconds(60) : negativeTtl;
|
||||||
|
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
|
||||||
|
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
|
||||||
|
semanticRegion = defaultText(semanticRegion, "default");
|
||||||
|
maximumValueBytes = maximumValueBytes == 0 ? 1_048_576 : maximumValueBytes;
|
||||||
|
maximumQueuedCommands = maximumQueuedCommands == 0 ? 8 : maximumQueuedCommands;
|
||||||
|
maximumInFlightBytes = maximumInFlightBytes == 0 ? 16_777_216 : maximumInFlightBytes;
|
||||||
|
if (host.length() > 253
|
||||||
|
|| host.chars().anyMatch(Character::isWhitespace)
|
||||||
|
|| host.contains("/")
|
||||||
|
|| host.contains("\\")) {
|
||||||
|
throw new IllegalArgumentException("Redis host is invalid");
|
||||||
|
}
|
||||||
|
if (port < 1 || port > 65_535) {
|
||||||
|
throw new IllegalArgumentException("Redis port must be in 1..65535");
|
||||||
|
}
|
||||||
|
positive(commandTimeout, MAXIMUM_TIMEOUT, "Redis command timeout");
|
||||||
|
positive(positiveTtl, MAXIMUM_TTL, "Redis positive TTL");
|
||||||
|
positive(negativeTtl, MAXIMUM_TTL, "Redis negative TTL");
|
||||||
|
slug(namespaceApplication, "Redis namespace application");
|
||||||
|
slug(namespaceEnvironment, "Redis namespace environment");
|
||||||
|
slug(semanticRegion, "Redis semantic region");
|
||||||
|
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
|
||||||
|
throw new IllegalArgumentException("Redis maximum value bytes must be in 1..16777216");
|
||||||
|
}
|
||||||
|
if (maximumQueuedCommands < 1 || maximumQueuedCommands > 4096) {
|
||||||
|
throw new IllegalArgumentException("Redis maximum queued commands must be in 1..4096");
|
||||||
|
}
|
||||||
|
if (maximumInFlightBytes < maximumValueBytes + 1024 || maximumInFlightBytes > 268_435_456) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Redis maximum in-flight bytes must cover one maximum value and be <= 268435456");
|
||||||
|
}
|
||||||
|
long maximumRetainedCommandBytes = (long) maximumQueuedCommands * (maximumValueBytes + 2048L);
|
||||||
|
if (maximumRetainedCommandBytes > maximumInFlightBytes) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Redis queued-command count and maximum value exceed the in-flight byte bound");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RedisRuntimeSettings(
|
||||||
|
boolean enabled,
|
||||||
|
ClientMode clientMode,
|
||||||
|
String host,
|
||||||
|
int port,
|
||||||
|
String password,
|
||||||
|
String keyHmacSecret,
|
||||||
|
Duration commandTimeout,
|
||||||
|
Duration positiveTtl,
|
||||||
|
Duration negativeTtl,
|
||||||
|
String namespaceApplication,
|
||||||
|
String namespaceEnvironment,
|
||||||
|
String semanticRegion,
|
||||||
|
int maximumValueBytes) {
|
||||||
|
this(
|
||||||
|
enabled,
|
||||||
|
clientMode,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
password,
|
||||||
|
keyHmacSecret,
|
||||||
|
commandTimeout,
|
||||||
|
positiveTtl,
|
||||||
|
negativeTtl,
|
||||||
|
namespaceApplication,
|
||||||
|
namespaceEnvironment,
|
||||||
|
semanticRegion,
|
||||||
|
maximumValueBytes,
|
||||||
|
8,
|
||||||
|
16_777_216);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selects the module-owned Lettuce runtime or an explicitly supplied {@link RedisClient}. */
|
||||||
|
public enum ClientMode {
|
||||||
|
MANAGED,
|
||||||
|
EXTERNAL
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] hmacSecret() {
|
||||||
|
byte[] decoded;
|
||||||
|
try {
|
||||||
|
decoded = Base64.getDecoder().decode(keyHmacSecret);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw new IllegalArgumentException("Redis key HMAC secret must be valid Base64", exception);
|
||||||
|
}
|
||||||
|
if (decoded.length < 32) {
|
||||||
|
throw new IllegalArgumentException("Redis key HMAC secret must contain at least 32 bytes");
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void positive(Duration value, Duration maximum, String field) {
|
||||||
|
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
|
||||||
|
throw new IllegalArgumentException(field + " must be positive and bounded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void slug(String value, String field) {
|
||||||
|
if (!value.matches("[a-z][a-z0-9-]{0,62}")) {
|
||||||
|
throw new IllegalArgumentException(field + " has invalid format");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String defaultText(String value, String fallback) {
|
||||||
|
return value == null || value.isBlank() ? fallback : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Incompatible;
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Negative;
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Positive;
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
|
||||||
|
import dev.caskeleton.application.cache.AuthoritativeAbsence;
|
||||||
|
import dev.caskeleton.application.cache.CacheInvalidationOutcome;
|
||||||
|
import dev.caskeleton.application.cache.CacheLookup;
|
||||||
|
import dev.caskeleton.application.cache.CacheRecordIntent;
|
||||||
|
import dev.caskeleton.application.cache.CacheRecordMetadata;
|
||||||
|
import dev.caskeleton.application.cache.CacheRecordOutcome;
|
||||||
|
import dev.caskeleton.application.cache.CacheRegionPort;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Semantic string-cache reference adapter using versioned envelopes and finite TTLs. */
|
||||||
|
final class RedisStringCacheRegion implements CacheRegionPort<String, String> {
|
||||||
|
|
||||||
|
private final RedisCacheRegionPolicy policy;
|
||||||
|
private final RedisBinaryCommands commands;
|
||||||
|
|
||||||
|
RedisStringCacheRegion(RedisCacheRegionPolicy policy, RedisBinaryCommands commands) {
|
||||||
|
this.policy = Objects.requireNonNull(policy, "policy must be non-null");
|
||||||
|
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CacheLookup<String> lookup(String key) {
|
||||||
|
byte[] physicalKey = physicalKey(key);
|
||||||
|
byte[] envelope;
|
||||||
|
try {
|
||||||
|
envelope = commands.get(physicalKey);
|
||||||
|
} catch (RedisValueTooLargeException exception) {
|
||||||
|
return new CacheLookup.IncompatibleSchema<>(
|
||||||
|
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE,
|
||||||
|
CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD);
|
||||||
|
} catch (RedisCommandFailureException exception) {
|
||||||
|
return new CacheLookup.Unavailable<>(
|
||||||
|
exception.kind() == RedisCommandFailureException.Kind.OVERLOADED
|
||||||
|
? CacheLookup.UnavailabilityReason.OVERLOADED
|
||||||
|
: CacheLookup.UnavailabilityReason.UNAVAILABLE,
|
||||||
|
certainty(exception));
|
||||||
|
}
|
||||||
|
if (envelope == null) {
|
||||||
|
return new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT);
|
||||||
|
}
|
||||||
|
var decoded = RedisCacheEnvelopeCodec.decode(envelope, policy.maximumValueBytes());
|
||||||
|
if (decoded instanceof Positive positive) {
|
||||||
|
return new CacheLookup.Hit<>(
|
||||||
|
positive.value(), CacheLookup.Freshness.FRESH, positive.sourceRevision());
|
||||||
|
}
|
||||||
|
if (decoded instanceof Negative negative) {
|
||||||
|
return new CacheLookup.NegativeHit<>(negative.reason());
|
||||||
|
}
|
||||||
|
Incompatible incompatible = (Incompatible) decoded;
|
||||||
|
return new CacheLookup.IncompatibleSchema<>(
|
||||||
|
incompatible.category(),
|
||||||
|
incompatible.category() == CacheLookup.SchemaCategory.FUTURE_VERSION
|
||||||
|
? CacheLookup.SchemaPolicy.FAIL_FAST
|
||||||
|
: CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) {
|
||||||
|
Objects.requireNonNull(metadata, "metadata must be non-null");
|
||||||
|
if (metadata.intent() == CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER) {
|
||||||
|
return CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY;
|
||||||
|
}
|
||||||
|
byte[] physicalKey = physicalKey(key);
|
||||||
|
byte[] envelope =
|
||||||
|
RedisCacheEnvelopeCodec.positive(
|
||||||
|
value, metadata.sourceRevision(), policy.maximumValueBytes());
|
||||||
|
return set(physicalKey, envelope, policy.positiveTtl());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CacheRecordOutcome recordAbsent(
|
||||||
|
String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) {
|
||||||
|
Objects.requireNonNull(metadata, "metadata must be non-null");
|
||||||
|
if (metadata.intent() == CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER) {
|
||||||
|
return CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY;
|
||||||
|
}
|
||||||
|
byte[] physicalKey = physicalKey(key);
|
||||||
|
byte[] envelope =
|
||||||
|
RedisCacheEnvelopeCodec.negative(
|
||||||
|
reason, metadata.sourceRevision(), policy.maximumValueBytes());
|
||||||
|
return set(physicalKey, envelope, policy.negativeTtl());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CacheInvalidationOutcome invalidate(String key) {
|
||||||
|
byte[] physicalKey = physicalKey(key);
|
||||||
|
try {
|
||||||
|
return commands.delete(physicalKey) > 0
|
||||||
|
? CacheInvalidationOutcome.INVALIDATED
|
||||||
|
: CacheInvalidationOutcome.ALREADY_ABSENT;
|
||||||
|
} catch (RedisCommandFailureException exception) {
|
||||||
|
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
|
||||||
|
? CacheInvalidationOutcome.DEGRADED_UNAVAILABLE
|
||||||
|
: CacheInvalidationOutcome.INDETERMINATE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CacheRecordOutcome set(
|
||||||
|
byte[] physicalKey, byte[] envelope, java.time.Duration timeToLive) {
|
||||||
|
try {
|
||||||
|
commands.set(physicalKey, envelope, timeToLive);
|
||||||
|
return CacheRecordOutcome.RECORDED;
|
||||||
|
} catch (RedisCommandFailureException exception) {
|
||||||
|
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
|
||||||
|
? CacheRecordOutcome.DEGRADED_UNAVAILABLE
|
||||||
|
: CacheRecordOutcome.INDETERMINATE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] physicalKey(String key) {
|
||||||
|
if (key == null || key.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("semantic cache key must be non-blank");
|
||||||
|
}
|
||||||
|
RedisKeyDigest digest =
|
||||||
|
RedisKeyDigest.sensitive(
|
||||||
|
policy.namespace().hashKeyVersion(),
|
||||||
|
policy.hmacSecret(),
|
||||||
|
List.of(key.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
return RedisKeyBuilder.build(policy.namespace(), digest).getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CacheLookup.OperationCertainty certainty(RedisCommandFailureException exception) {
|
||||||
|
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
|
||||||
|
? CacheLookup.OperationCertainty.NOT_APPLIED
|
||||||
|
: CacheLookup.OperationCertainty.INDETERMINATE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
/** Signals that Redis contains a value larger than this runtime is allowed to receive. */
|
||||||
|
final class RedisValueTooLargeException extends RuntimeException {
|
||||||
|
|
||||||
|
RedisValueTooLargeException() {
|
||||||
|
super("Redis value exceeds the configured receive bound");
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis.key;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Sole physical key constructor for the Redis capability foundation. */
|
||||||
|
public final class RedisKeyBuilder {
|
||||||
|
|
||||||
|
private RedisKeyBuilder() {}
|
||||||
|
|
||||||
|
public static String build(RedisKeyNamespace namespace, RedisKeyDigest digest) {
|
||||||
|
Objects.requireNonNull(namespace, "namespace must be non-null");
|
||||||
|
Objects.requireNonNull(digest, "digest must be non-null");
|
||||||
|
if (namespace.hashKeyVersion() != digest.hashKeyVersion()) {
|
||||||
|
throw new IllegalArgumentException("namespace and digest hash key version must match");
|
||||||
|
}
|
||||||
|
String key =
|
||||||
|
"ca:%s:%s:%s:%s:hv%d:kv%d:{%s}:%s:%s"
|
||||||
|
.formatted(
|
||||||
|
namespace.application(),
|
||||||
|
namespace.environment(),
|
||||||
|
namespace.capability(),
|
||||||
|
namespace.region(),
|
||||||
|
namespace.hashKeyVersion(),
|
||||||
|
namespace.keyVersion(),
|
||||||
|
digest.slotTag(),
|
||||||
|
digest.resourceDigest(),
|
||||||
|
namespace.kind());
|
||||||
|
int byteSize = key.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
if (byteSize > namespace.maximumKeyBytes()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"physical Redis key exceeds maximum bytes: "
|
||||||
|
+ byteSize
|
||||||
|
+ " > "
|
||||||
|
+ namespace.maximumKeyBytes());
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis.key;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
/** Precomputed digest and cluster slot tag; raw resource identifiers are never retained. */
|
||||||
|
public record RedisKeyDigest(int hashKeyVersion, String slotTag, String resourceDigest) {
|
||||||
|
|
||||||
|
private static final int MAXIMUM_COMPONENT_BYTES = 4_096;
|
||||||
|
private static final int MAXIMUM_CANONICAL_BYTES = 16_384;
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
|
public RedisKeyDigest {
|
||||||
|
if (hashKeyVersion < 1 || hashKeyVersion > 9_999) {
|
||||||
|
throw new IllegalArgumentException("hashKeyVersion must be in 1..9999");
|
||||||
|
}
|
||||||
|
if (slotTag == null || !slotTag.matches("[0-9a-f]{8}")) {
|
||||||
|
throw new IllegalArgumentException("slotTag must be 8 lowercase hexadecimal characters");
|
||||||
|
}
|
||||||
|
if (resourceDigest == null || !resourceDigest.matches("[0-9a-f]{64}")) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"resourceDigest must be 64 lowercase hexadecimal characters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RedisKeyDigest opaque(int hashKeyVersion, List<byte[]> components) {
|
||||||
|
return fromBytes(hashKeyVersion, sha256(canonicalComponents(components)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RedisKeyDigest sensitive(
|
||||||
|
int hashKeyVersion, byte[] secret, List<byte[]> components) {
|
||||||
|
Objects.requireNonNull(secret, "secret must be non-null");
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new IllegalArgumentException("HMAC secret must contain at least 32 bytes");
|
||||||
|
}
|
||||||
|
byte[] secretCopy = secret.clone();
|
||||||
|
try {
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA256");
|
||||||
|
mac.init(new SecretKeySpec(secretCopy, "HmacSHA256"));
|
||||||
|
return fromBytes(hashKeyVersion, mac.doFinal(canonicalComponents(components)));
|
||||||
|
} catch (GeneralSecurityException exception) {
|
||||||
|
throw new IllegalStateException("HmacSHA256 unavailable", exception);
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(secretCopy, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RedisKeyDigest fromBytes(int hashKeyVersion, byte[] digest) {
|
||||||
|
String hexadecimal = HEX.formatHex(digest);
|
||||||
|
return new RedisKeyDigest(hashKeyVersion, hexadecimal.substring(0, 8), hexadecimal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] canonicalComponents(List<byte[]> components) {
|
||||||
|
Objects.requireNonNull(components, "components must be non-null");
|
||||||
|
if (components.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("at least one digest component is required");
|
||||||
|
}
|
||||||
|
int size = 0;
|
||||||
|
for (byte[] component : components) {
|
||||||
|
Objects.requireNonNull(component, "digest component must be non-null");
|
||||||
|
if (component.length > MAXIMUM_COMPONENT_BYTES) {
|
||||||
|
throw new IllegalArgumentException("digest component exceeds maximum bytes");
|
||||||
|
}
|
||||||
|
size = Math.addExact(size, Integer.BYTES + component.length);
|
||||||
|
if (size > MAXIMUM_CANONICAL_BYTES) {
|
||||||
|
throw new IllegalArgumentException("canonical digest input exceeds maximum bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ByteBuffer buffer = ByteBuffer.allocate(size);
|
||||||
|
for (byte[] component : components) {
|
||||||
|
buffer.putInt(component.length);
|
||||||
|
buffer.put(component);
|
||||||
|
}
|
||||||
|
return buffer.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] sha256(byte[] input) {
|
||||||
|
try {
|
||||||
|
return MessageDigest.getInstance("SHA-256").digest(input);
|
||||||
|
} catch (GeneralSecurityException exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 unavailable", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis.key;
|
||||||
|
|
||||||
|
/** Validated non-sensitive namespace segments for one physical Redis key family. */
|
||||||
|
public record RedisKeyNamespace(
|
||||||
|
String application,
|
||||||
|
String environment,
|
||||||
|
String capability,
|
||||||
|
String region,
|
||||||
|
int hashKeyVersion,
|
||||||
|
int keyVersion,
|
||||||
|
String kind,
|
||||||
|
int maximumKeyBytes) {
|
||||||
|
|
||||||
|
public RedisKeyNamespace {
|
||||||
|
validateSlug(application, "application");
|
||||||
|
validateSlug(environment, "environment");
|
||||||
|
validateSlug(capability, "capability");
|
||||||
|
validateSlug(region, "region");
|
||||||
|
validateSlug(kind, "kind");
|
||||||
|
if (hashKeyVersion < 1 || hashKeyVersion > 9_999) {
|
||||||
|
throw new IllegalArgumentException("hashKeyVersion must be in 1..9999");
|
||||||
|
}
|
||||||
|
if (keyVersion < 1 || keyVersion > 9_999) {
|
||||||
|
throw new IllegalArgumentException("keyVersion must be in 1..9999");
|
||||||
|
}
|
||||||
|
if (maximumKeyBytes < 1 || maximumKeyBytes > 4_096) {
|
||||||
|
throw new IllegalArgumentException("maximumKeyBytes must be in 1..4096 bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateSlug(String value, String field) {
|
||||||
|
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
|
||||||
|
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9-]{0,62}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"programSet": "ca-redis-programs-v1-foundation",
|
||||||
|
"minimumRedisVersion": "7.2",
|
||||||
|
"resultSchemaVersion": 1,
|
||||||
|
"readiness": "R0",
|
||||||
|
"programs": [
|
||||||
|
{
|
||||||
|
"id": "compare-and-delete-v1",
|
||||||
|
"scriptResource": "redis/scripts/compare-and-delete-v1.lua",
|
||||||
|
"sha256": "d0fa9beaa37353ec96be36e3158e06b33165b15489c67e9ca8e4800dac09b25a",
|
||||||
|
"keyCount": 1,
|
||||||
|
"argumentCount": 1,
|
||||||
|
"statuses": ["DELETED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID"],
|
||||||
|
"complexity": "O(1)",
|
||||||
|
"timeoutCertainty": "INDETERMINATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "compare-and-expire-v1",
|
||||||
|
"scriptResource": "redis/scripts/compare-and-expire-v1.lua",
|
||||||
|
"sha256": "5665fe349f2800c061ff3c86ec33ff11cb6706ee35c605b8e68db21cd08bd7e0",
|
||||||
|
"keyCount": 1,
|
||||||
|
"argumentCount": 2,
|
||||||
|
"statuses": ["RENEWED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID"],
|
||||||
|
"complexity": "O(1)",
|
||||||
|
"timeoutCertainty": "INDETERMINATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "set-if-absent-with-ttl-v1",
|
||||||
|
"scriptResource": "redis/scripts/set-if-absent-with-ttl-v1.lua",
|
||||||
|
"sha256": "777014f7a23435b5701e2d0d286aef60a2f5d54a7836e94122527b28098dc010",
|
||||||
|
"keyCount": 1,
|
||||||
|
"argumentCount": 3,
|
||||||
|
"statuses": ["SET", "EXISTS", "WRONG_TYPE", "INVALID"],
|
||||||
|
"complexity": "O(1)",
|
||||||
|
"timeoutCertainty": "INDETERMINATE"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
local function key_type(key)
|
||||||
|
local result = redis.call('TYPE', key)
|
||||||
|
if type(result) == 'table' then
|
||||||
|
return result['ok']
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
if #KEYS ~= 1 or #ARGV ~= 1 or string.len(ARGV[1]) == 0 or string.len(ARGV[1]) > 128 then
|
||||||
|
return 'INVALID'
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_type = key_type(KEYS[1])
|
||||||
|
if current_type == 'none' then
|
||||||
|
return 'ABSENT'
|
||||||
|
end
|
||||||
|
if current_type ~= 'string' then
|
||||||
|
return 'WRONG_TYPE'
|
||||||
|
end
|
||||||
|
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
|
||||||
|
return 'NOT_OWNER'
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call('DEL', KEYS[1])
|
||||||
|
return 'DELETED'
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
local function key_type(key)
|
||||||
|
local result = redis.call('TYPE', key)
|
||||||
|
if type(result) == 'table' then
|
||||||
|
return result['ok']
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local ttl = tonumber(ARGV[2])
|
||||||
|
if #KEYS ~= 1 or #ARGV ~= 2 or string.len(ARGV[1]) == 0 or string.len(ARGV[1]) > 128
|
||||||
|
or ttl == nil or ttl < 1 then
|
||||||
|
return 'INVALID'
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_type = key_type(KEYS[1])
|
||||||
|
if current_type == 'none' then
|
||||||
|
return 'ABSENT'
|
||||||
|
end
|
||||||
|
if current_type ~= 'string' then
|
||||||
|
return 'WRONG_TYPE'
|
||||||
|
end
|
||||||
|
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
|
||||||
|
return 'NOT_OWNER'
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call('PEXPIRE', KEYS[1], ttl)
|
||||||
|
return 'RENEWED'
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
local function key_type(key)
|
||||||
|
local result = redis.call('TYPE', key)
|
||||||
|
if type(result) == 'table' then
|
||||||
|
return result['ok']
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local ttl = tonumber(ARGV[2])
|
||||||
|
if #KEYS ~= 1 or #ARGV ~= 3 or string.len(ARGV[1]) == 0
|
||||||
|
or ttl == nil or ttl < 1 or string.len(ARGV[3]) == 0 or string.len(ARGV[3]) > 128 then
|
||||||
|
return 'INVALID'
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_type = key_type(KEYS[1])
|
||||||
|
if current_type ~= 'none' and current_type ~= 'string' then
|
||||||
|
return 'WRONG_TYPE'
|
||||||
|
end
|
||||||
|
|
||||||
|
local applied = redis.call('SET', KEYS[1], ARGV[1], 'PX', ttl, 'NX')
|
||||||
|
if applied then
|
||||||
|
return 'SET'
|
||||||
|
end
|
||||||
|
return 'EXISTS'
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||||
|
import dev.caskeleton.application.cache.CacheLookup;
|
||||||
|
import dev.caskeleton.application.cache.CacheRecordIntent;
|
||||||
|
import dev.caskeleton.application.cache.CacheRecordMetadata;
|
||||||
|
import dev.caskeleton.application.cache.CacheRecordOutcome;
|
||||||
|
import io.lettuce.core.api.StatefulRedisConnection;
|
||||||
|
import io.lettuce.core.codec.ByteArrayCodec;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
@Tag("redis-service")
|
||||||
|
class LettuceRedisRuntimeServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void executesRealTtlExpiryAndCatalogLuaAgainstStandaloneRedis() throws InterruptedException {
|
||||||
|
RedisRuntimeSettings settings = settings();
|
||||||
|
LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings);
|
||||||
|
try {
|
||||||
|
RedisStringCacheRegion region =
|
||||||
|
new RedisStringCacheRegion(
|
||||||
|
new RedisCacheRegionPolicy(
|
||||||
|
new RedisKeyNamespace(
|
||||||
|
"ca-skeleton", "test", "cache", "service", 1, 1, "entry", 512),
|
||||||
|
settings.hmacSecret(),
|
||||||
|
Duration.ofMillis(150),
|
||||||
|
Duration.ofSeconds(1),
|
||||||
|
1024),
|
||||||
|
runtime);
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
region.record(
|
||||||
|
"service-key",
|
||||||
|
"service-value",
|
||||||
|
new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)))
|
||||||
|
.isEqualTo(CacheRecordOutcome.RECORDED);
|
||||||
|
assertThat(region.lookup("service-key"))
|
||||||
|
.isEqualTo(
|
||||||
|
new CacheLookup.Hit<>("service-value", CacheLookup.Freshness.FRESH, "revision-1"));
|
||||||
|
awaitMiss(region, "service-key");
|
||||||
|
|
||||||
|
byte[] leaseKey = "ca:test:lease:{service}".getBytes(UTF_8);
|
||||||
|
runtime.set(leaseKey, "owner-1".getBytes(UTF_8), Duration.ofSeconds(5));
|
||||||
|
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||||
|
RedisProgramDescriptor compareDelete =
|
||||||
|
catalog.descriptors().stream()
|
||||||
|
.filter(descriptor -> descriptor.argumentCount() == 1)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, runtime);
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
executor.execute(
|
||||||
|
compareDelete, List.of(leaseKey), List.of("owner-1".getBytes(UTF_8))))
|
||||||
|
.isEqualTo("DELETED");
|
||||||
|
assertThat(runtime.get(leaseKey)).isNull();
|
||||||
|
} finally {
|
||||||
|
runtime.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> runtime.get("closed".getBytes(UTF_8)))
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAnOversizedBulkValueBeforeReturningItToTheSemanticDecoder() {
|
||||||
|
RedisRuntimeSettings settings = settings();
|
||||||
|
RedisKeyNamespace namespace =
|
||||||
|
new RedisKeyNamespace("ca-skeleton", "test", "cache", "service", 1, 1, "entry", 512);
|
||||||
|
byte[] semanticKey = "oversized-service-key".getBytes(UTF_8);
|
||||||
|
byte[] physicalKey =
|
||||||
|
RedisKeyBuilder.build(
|
||||||
|
namespace,
|
||||||
|
RedisKeyDigest.sensitive(
|
||||||
|
namespace.hashKeyVersion(), settings.hmacSecret(), List.of(semanticKey)))
|
||||||
|
.getBytes(UTF_8);
|
||||||
|
byte[] oversizedValue = new byte[settings.maximumValueBytes() + 4096];
|
||||||
|
Arrays.fill(oversizedValue, (byte) 'x');
|
||||||
|
|
||||||
|
io.lettuce.core.RedisClient unboundedClient =
|
||||||
|
io.lettuce.core.RedisClient.create(LettuceRedisRuntime.redisUri(settings));
|
||||||
|
try (StatefulRedisConnection<byte[], byte[]> unboundedConnection =
|
||||||
|
unboundedClient.connect(ByteArrayCodec.INSTANCE);
|
||||||
|
LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings)) {
|
||||||
|
unboundedConnection.sync().set(physicalKey, oversizedValue);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> runtime.get(physicalKey))
|
||||||
|
.isInstanceOf(RedisValueTooLargeException.class);
|
||||||
|
|
||||||
|
RedisStringCacheRegion region =
|
||||||
|
new RedisStringCacheRegion(
|
||||||
|
new RedisCacheRegionPolicy(
|
||||||
|
namespace,
|
||||||
|
settings.hmacSecret(),
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(1),
|
||||||
|
settings.maximumValueBytes()),
|
||||||
|
runtime);
|
||||||
|
assertThat(region.lookup(new String(semanticKey, UTF_8)))
|
||||||
|
.isEqualTo(
|
||||||
|
new CacheLookup.IncompatibleSchema<>(
|
||||||
|
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE,
|
||||||
|
CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD));
|
||||||
|
} finally {
|
||||||
|
unboundedClient.shutdown(Duration.ZERO, settings.commandTimeout());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void awaitMiss(RedisStringCacheRegion region, String key)
|
||||||
|
throws InterruptedException {
|
||||||
|
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
|
||||||
|
CacheLookup<String> result;
|
||||||
|
do {
|
||||||
|
result = region.lookup(key);
|
||||||
|
if (result instanceof CacheLookup.Miss<String>) {
|
||||||
|
assertThat(result).isEqualTo(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Thread.sleep(20);
|
||||||
|
} while (System.nanoTime() < deadline);
|
||||||
|
throw new AssertionError(
|
||||||
|
"Redis key did not expire within the qualification deadline: " + result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RedisRuntimeSettings settings() {
|
||||||
|
String host = requiredProperty("redis.test.host");
|
||||||
|
int port = Integer.parseInt(requiredProperty("redis.test.port"));
|
||||||
|
return new RedisRuntimeSettings(
|
||||||
|
true,
|
||||||
|
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
"",
|
||||||
|
Base64.getEncoder().encodeToString(new byte[32]),
|
||||||
|
Duration.ofSeconds(2),
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
"ca-skeleton",
|
||||||
|
"test",
|
||||||
|
"service",
|
||||||
|
1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requiredProperty(String name) {
|
||||||
|
String value = System.getProperty(name);
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new AssertionError("real Redis lane requires -D" + name);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import io.lettuce.core.ClientOptions;
|
||||||
|
import io.lettuce.core.RedisURI;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Base64;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class LettuceRedisRuntimeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildsAnExactFiniteStandaloneRedisUri() {
|
||||||
|
RedisRuntimeSettings settings =
|
||||||
|
new RedisRuntimeSettings(
|
||||||
|
true,
|
||||||
|
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||||
|
"127.0.0.1",
|
||||||
|
6380,
|
||||||
|
"secret-value",
|
||||||
|
Base64.getEncoder().encodeToString(new byte[32]),
|
||||||
|
Duration.ofSeconds(2),
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
"ca-skeleton",
|
||||||
|
"test",
|
||||||
|
"worklog",
|
||||||
|
1024);
|
||||||
|
|
||||||
|
RedisURI uri = LettuceRedisRuntime.redisUri(settings);
|
||||||
|
|
||||||
|
assertThat(uri.getHost()).isEqualTo("127.0.0.1");
|
||||||
|
assertThat(uri.getPort()).isEqualTo(6380);
|
||||||
|
assertThat(uri.getTimeout()).isEqualTo(Duration.ofSeconds(2));
|
||||||
|
assertThat(uri.toString()).doesNotContain("secret-value");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disablesReconnectReplayAndBoundsEveryOutstandingCommand() {
|
||||||
|
RedisRuntimeSettings settings =
|
||||||
|
new RedisRuntimeSettings(
|
||||||
|
true,
|
||||||
|
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||||
|
"127.0.0.1",
|
||||||
|
6380,
|
||||||
|
"",
|
||||||
|
Base64.getEncoder().encodeToString(new byte[32]),
|
||||||
|
Duration.ofSeconds(2),
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
"ca-skeleton",
|
||||||
|
"test",
|
||||||
|
"worklog",
|
||||||
|
1024,
|
||||||
|
17,
|
||||||
|
65_536);
|
||||||
|
|
||||||
|
ClientOptions options = LettuceRedisRuntime.clientOptions(settings);
|
||||||
|
|
||||||
|
assertThat(options.isAutoReconnect()).isTrue();
|
||||||
|
assertThat(options.getReplayFilter().test(null)).isTrue();
|
||||||
|
assertThat(options.getDisconnectedBehavior())
|
||||||
|
.isEqualTo(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS);
|
||||||
|
assertThat(options.getRequestQueueSize()).isEqualTo(17);
|
||||||
|
assertThat(options.getTimeoutOptions().isTimeoutCommands()).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class RedisAtomicPrimitivesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapsCompareDeleteStatusThroughTheTypedFacade() {
|
||||||
|
CapturingExecutor executor = new CapturingExecutor("NOT_OWNER");
|
||||||
|
RedisAtomicPrimitives primitives =
|
||||||
|
new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor);
|
||||||
|
|
||||||
|
RedisAtomicPrimitives.CompareDeleteResult result =
|
||||||
|
primitives.compareAndDelete("lease-key", "owner-1".getBytes(UTF_8));
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(RedisAtomicPrimitives.CompareDeleteResult.NOT_OWNER);
|
||||||
|
assertThat(executor.programId).isEqualTo(RedisProgramId.COMPARE_AND_DELETE);
|
||||||
|
assertThat(executor.keys).containsExactly("lease-key".getBytes(UTF_8));
|
||||||
|
assertThat(executor.arguments).containsExactly("owner-1".getBytes(UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesTtlAndArgumentBoundsBeforeCallingRedis() {
|
||||||
|
CapturingExecutor executor = new CapturingExecutor("RENEWED");
|
||||||
|
RedisAtomicPrimitives primitives =
|
||||||
|
new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor);
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
primitives.compareAndExpire("lease-key", "owner-1".getBytes(UTF_8), Duration.ZERO))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("TTL");
|
||||||
|
assertThat(executor.calls).hasValue(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownProgramStatusAsCompatibilityFailure() {
|
||||||
|
RedisAtomicPrimitives primitives =
|
||||||
|
new RedisAtomicPrimitives(
|
||||||
|
RedisProgramCatalog.foundation(), new CapturingExecutor("NEW_SERVER_STATUS"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> primitives.compareAndDelete("lease-key", "owner-1".getBytes(UTF_8)))
|
||||||
|
.isInstanceOf(RedisProgramCompatibilityException.class)
|
||||||
|
.hasMessageContaining("NEW_SERVER_STATUS");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CapturingExecutor implements RedisProgramExecutor {
|
||||||
|
|
||||||
|
private final String status;
|
||||||
|
private final AtomicInteger calls = new AtomicInteger();
|
||||||
|
private RedisProgramId programId;
|
||||||
|
private List<byte[]> keys;
|
||||||
|
private List<byte[]> arguments;
|
||||||
|
|
||||||
|
private CapturingExecutor(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(
|
||||||
|
RedisProgramDescriptor descriptor, List<byte[]> keys, List<byte[]> arguments) {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
programId = descriptor.id();
|
||||||
|
this.keys = keys;
|
||||||
|
this.arguments = arguments;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class RedisCommandAdmissionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsWhenEitherCommandCountOrRetainedBytesAreSaturatedAndReleasesExactlyOnce() {
|
||||||
|
RedisCommandAdmission admission = new RedisCommandAdmission(2, 100);
|
||||||
|
RedisCommandAdmission.Lease first = admission.tryAcquire(80);
|
||||||
|
|
||||||
|
assertThat(first).isNotNull();
|
||||||
|
assertThat(admission.tryAcquire(21)).isNull();
|
||||||
|
|
||||||
|
first.close();
|
||||||
|
first.close();
|
||||||
|
RedisCommandAdmission.Lease second = admission.tryAcquire(100);
|
||||||
|
assertThat(second).isNotNull();
|
||||||
|
assertThat(admission.tryAcquire(1)).isNull();
|
||||||
|
second.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class RedisLuaProgramExecutorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fallsBackToEvalOnlyWhenEvalShaReportsNoScript() {
|
||||||
|
FakeCommands commands = new FakeCommands();
|
||||||
|
commands.noScript = true;
|
||||||
|
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||||
|
RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands);
|
||||||
|
RedisProgramDescriptor descriptor = singleArgumentDescriptor(catalog);
|
||||||
|
|
||||||
|
String status =
|
||||||
|
executor.execute(
|
||||||
|
descriptor, List.of("key".getBytes(UTF_8)), List.of("owner".getBytes(UTF_8)));
|
||||||
|
|
||||||
|
assertThat(status).isEqualTo("DELETED");
|
||||||
|
assertThat(commands.evalShaCalls).hasValue(1);
|
||||||
|
assertThat(commands.evalCalls).hasValue(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotEvalAgainWhenCachedScriptExecutes() {
|
||||||
|
FakeCommands commands = new FakeCommands();
|
||||||
|
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||||
|
RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands);
|
||||||
|
RedisProgramDescriptor descriptor = singleArgumentDescriptor(catalog);
|
||||||
|
|
||||||
|
executor.execute(descriptor, List.of("key".getBytes(UTF_8)), List.of("owner".getBytes(UTF_8)));
|
||||||
|
|
||||||
|
assertThat(commands.evalShaCalls).hasValue(1);
|
||||||
|
assertThat(commands.evalCalls).hasValue(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDescriptorsOutsideItsClosedCatalogBeforeExecutingAnything() {
|
||||||
|
FakeCommands commands = new FakeCommands();
|
||||||
|
RedisProgramCatalog ownedCatalog = RedisProgramCatalog.foundation();
|
||||||
|
RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(ownedCatalog, commands);
|
||||||
|
RedisProgramDescriptor foreignDescriptor =
|
||||||
|
singleArgumentDescriptor(RedisProgramCatalog.foundation());
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
executor.execute(
|
||||||
|
foreignDescriptor,
|
||||||
|
List.of("key".getBytes(UTF_8)),
|
||||||
|
List.of("owner".getBytes(UTF_8))))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("not owned");
|
||||||
|
assertThat(commands.evalShaCalls).hasValue(0);
|
||||||
|
assertThat(commands.evalCalls).hasValue(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsStatusesOutsideTheCompiledProgramContract() {
|
||||||
|
FakeCommands commands = new FakeCommands();
|
||||||
|
commands.result = "UNDECLARED";
|
||||||
|
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||||
|
RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands);
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
executor.execute(
|
||||||
|
singleArgumentDescriptor(catalog),
|
||||||
|
List.of("key".getBytes(UTF_8)),
|
||||||
|
List.of("owner".getBytes(UTF_8))))
|
||||||
|
.isInstanceOf(RedisProgramCompatibilityException.class)
|
||||||
|
.hasMessageContaining("UNDECLARED");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RedisProgramDescriptor singleArgumentDescriptor(RedisProgramCatalog catalog) {
|
||||||
|
return catalog.descriptors().stream()
|
||||||
|
.filter(descriptor -> descriptor.keyCount() == 1 && descriptor.argumentCount() == 1)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeCommands implements RedisBinaryCommands {
|
||||||
|
|
||||||
|
private final AtomicInteger evalShaCalls = new AtomicInteger();
|
||||||
|
private final AtomicInteger evalCalls = new AtomicInteger();
|
||||||
|
private boolean noScript;
|
||||||
|
private String result = "DELETED";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] get(byte[] key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(byte[] key, byte[] value, Duration timeToLive) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long delete(byte[] key) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments) {
|
||||||
|
evalShaCalls.incrementAndGet();
|
||||||
|
if (noScript) {
|
||||||
|
throw new RedisNoScriptException();
|
||||||
|
}
|
||||||
|
return result.getBytes(UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments) {
|
||||||
|
evalCalls.incrementAndGet();
|
||||||
|
return result.getBytes(UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import com.jayway.jsonpath.JsonPath;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class RedisProgramCatalogTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadsEveryFoundationProgramWithAnExactDigestAndBoundedSignature() {
|
||||||
|
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||||
|
|
||||||
|
assertThat(catalog.descriptors()).hasSize(3);
|
||||||
|
assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE).keyCount()).isEqualTo(1);
|
||||||
|
assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE).argumentCount()).isEqualTo(1);
|
||||||
|
assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_EXPIRE).argumentCount()).isEqualTo(2);
|
||||||
|
assertThat(catalog.descriptor(RedisProgramId.SET_IF_ABSENT_WITH_TTL).argumentCount())
|
||||||
|
.isEqualTo(3);
|
||||||
|
|
||||||
|
catalog
|
||||||
|
.descriptors()
|
||||||
|
.forEach(
|
||||||
|
descriptor -> {
|
||||||
|
assertThat(descriptor.sha256()).matches("[0-9a-f]{64}");
|
||||||
|
assertThat(descriptor.scriptBytes()).isNotEmpty();
|
||||||
|
assertThat(new String(descriptor.scriptBytes(), StandardCharsets.UTF_8))
|
||||||
|
.contains("redis.call");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsDefensiveScriptCopies() {
|
||||||
|
RedisProgramDescriptor descriptor =
|
||||||
|
RedisProgramCatalog.foundation().descriptor(RedisProgramId.COMPARE_AND_DELETE);
|
||||||
|
byte[] first = descriptor.scriptBytes();
|
||||||
|
first[0] = 0;
|
||||||
|
|
||||||
|
assertThat(descriptor.scriptBytes()[0]).isNotZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void machineReadableManifestMatchesTheCompiledCatalog() throws IOException {
|
||||||
|
String manifest;
|
||||||
|
try (InputStream input =
|
||||||
|
RedisProgramCatalogTest.class
|
||||||
|
.getClassLoader()
|
||||||
|
.getResourceAsStream("redis/program-set.json")) {
|
||||||
|
assertThat(input).isNotNull();
|
||||||
|
manifest = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(JsonPath.<String>read(manifest, "$.readiness")).isEqualTo("R0");
|
||||||
|
List<Map<String, Object>> programs = JsonPath.read(manifest, "$.programs");
|
||||||
|
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||||
|
assertThat(programs).hasSameSizeAs(catalog.descriptors());
|
||||||
|
programs.forEach(
|
||||||
|
program -> {
|
||||||
|
RedisProgramId id =
|
||||||
|
catalog.descriptors().stream()
|
||||||
|
.map(RedisProgramDescriptor::id)
|
||||||
|
.filter(candidate -> candidate.externalId().equals(program.get("id")))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
assertThat(program.get("sha256")).isEqualTo(catalog.descriptor(id).sha256());
|
||||||
|
assertThat(Set.copyOf((List<?>) program.get("statuses")))
|
||||||
|
.isEqualTo(catalog.descriptor(id).statuses());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Base64;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class RedisRuntimeSettingsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesFiniteTimeoutTtlPortAndStableHmacSecret() {
|
||||||
|
RedisRuntimeSettings settings =
|
||||||
|
new RedisRuntimeSettings(
|
||||||
|
true,
|
||||||
|
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||||
|
"localhost",
|
||||||
|
6379,
|
||||||
|
"",
|
||||||
|
Base64.getEncoder().encodeToString(new byte[32]),
|
||||||
|
Duration.ofSeconds(2),
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
"ca-skeleton",
|
||||||
|
"test",
|
||||||
|
"worklog",
|
||||||
|
1024);
|
||||||
|
|
||||||
|
assertThat(settings.port()).isEqualTo(6379);
|
||||||
|
assertThat(settings.hmacSecret()).hasSize(32);
|
||||||
|
assertThat(settings.positiveTtl()).isEqualTo(Duration.ofMinutes(5));
|
||||||
|
assertThat(settings.maximumQueuedCommands()).isEqualTo(8);
|
||||||
|
assertThat(settings.maximumInFlightBytes()).isEqualTo(16_777_216);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enabledRuntimeRejectsShortOrMissingHmacSecret() {
|
||||||
|
RedisRuntimeSettings settings =
|
||||||
|
new RedisRuntimeSettings(
|
||||||
|
true,
|
||||||
|
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||||
|
"localhost",
|
||||||
|
6379,
|
||||||
|
"",
|
||||||
|
"c2hvcnQ=",
|
||||||
|
Duration.ofSeconds(2),
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
"ca-skeleton",
|
||||||
|
"test",
|
||||||
|
"worklog",
|
||||||
|
1024);
|
||||||
|
|
||||||
|
assertThatThrownBy(settings::hmacSecret)
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("HMAC");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enabledManagedRuntimeRejectsAMissingHostInsteadOfSilentlyUsingLocalhost() {
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
new RedisRuntimeSettings(
|
||||||
|
true,
|
||||||
|
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||||
|
" ",
|
||||||
|
6379,
|
||||||
|
"",
|
||||||
|
Base64.getEncoder().encodeToString(new byte[32]),
|
||||||
|
Duration.ofSeconds(2),
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
"ca-skeleton",
|
||||||
|
"test",
|
||||||
|
"worklog",
|
||||||
|
1024))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("host");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnboundedDurationsAndInvalidPort() {
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
new RedisRuntimeSettings(
|
||||||
|
true,
|
||||||
|
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||||
|
"localhost",
|
||||||
|
-1,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
Duration.ZERO,
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
"ca-skeleton",
|
||||||
|
"test",
|
||||||
|
"worklog",
|
||||||
|
1024))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAnUnboundedCommandQueue() {
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
new RedisRuntimeSettings(
|
||||||
|
true,
|
||||||
|
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||||
|
"localhost",
|
||||||
|
6379,
|
||||||
|
"",
|
||||||
|
Base64.getEncoder().encodeToString(new byte[32]),
|
||||||
|
Duration.ofSeconds(2),
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
"ca-skeleton",
|
||||||
|
"test",
|
||||||
|
"worklog",
|
||||||
|
1024,
|
||||||
|
4097,
|
||||||
|
16_777_216))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("queued");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsByteCapacityThatCannotHoldOneMaximumValue() {
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
new RedisRuntimeSettings(
|
||||||
|
true,
|
||||||
|
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||||
|
"localhost",
|
||||||
|
6379,
|
||||||
|
"",
|
||||||
|
Base64.getEncoder().encodeToString(new byte[32]),
|
||||||
|
Duration.ofSeconds(2),
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
"ca-skeleton",
|
||||||
|
"test",
|
||||||
|
"worklog",
|
||||||
|
1_048_576,
|
||||||
|
16,
|
||||||
|
1_048_576))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("in-flight bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsQueueAndValueBoundsWhoseWorstRetainedPayloadExceedsTheByteBudget() {
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
new RedisRuntimeSettings(
|
||||||
|
true,
|
||||||
|
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||||
|
"localhost",
|
||||||
|
6379,
|
||||||
|
"",
|
||||||
|
Base64.getEncoder().encodeToString(new byte[32]),
|
||||||
|
Duration.ofSeconds(2),
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
"ca-skeleton",
|
||||||
|
"test",
|
||||||
|
"worklog",
|
||||||
|
1_048_576,
|
||||||
|
64,
|
||||||
|
16_777_216))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("queued-command count");
|
||||||
|
}
|
||||||
|
}
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||||
|
import dev.caskeleton.application.cache.AuthoritativeAbsence;
|
||||||
|
import dev.caskeleton.application.cache.CacheInvalidationOutcome;
|
||||||
|
import dev.caskeleton.application.cache.CacheLookup;
|
||||||
|
import dev.caskeleton.application.cache.CacheRecordIntent;
|
||||||
|
import dev.caskeleton.application.cache.CacheRecordMetadata;
|
||||||
|
import dev.caskeleton.application.cache.CacheRecordOutcome;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class RedisStringCacheRegionTest {
|
||||||
|
|
||||||
|
private FakeCommands commands;
|
||||||
|
private RedisStringCacheRegion region;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
commands = new FakeCommands();
|
||||||
|
region =
|
||||||
|
new RedisStringCacheRegion(
|
||||||
|
new RedisCacheRegionPolicy(
|
||||||
|
new RedisKeyNamespace(
|
||||||
|
"ca-skeleton", "test", "cache", "worklog", 1, 1, "entry", 512),
|
||||||
|
new byte[32],
|
||||||
|
Duration.ofMinutes(5),
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
1024),
|
||||||
|
commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordsAndReadsPositiveEntryWithBoundedTtl() {
|
||||||
|
CacheRecordOutcome outcome =
|
||||||
|
region.record(
|
||||||
|
"tenant-1:work-1",
|
||||||
|
"cached-value",
|
||||||
|
new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT));
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(CacheRecordOutcome.RECORDED);
|
||||||
|
assertThat(commands.lastTtl).isEqualTo(Duration.ofMinutes(5));
|
||||||
|
assertThat(new String(commands.lastKey, UTF_8)).doesNotContain("tenant-1");
|
||||||
|
assertThat(region.lookup("tenant-1:work-1"))
|
||||||
|
.isEqualTo(
|
||||||
|
new CacheLookup.Hit<>("cached-value", CacheLookup.Freshness.FRESH, "revision-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordsAndReadsAuthoritativeNegativeEntryWithShorterTtl() {
|
||||||
|
CacheRecordOutcome outcome =
|
||||||
|
region.recordAbsent(
|
||||||
|
"tenant-1:missing",
|
||||||
|
AuthoritativeAbsence.NOT_FOUND,
|
||||||
|
new CacheRecordMetadata("revision-2", CacheRecordIntent.UPSERT));
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(CacheRecordOutcome.RECORDED);
|
||||||
|
assertThat(commands.lastTtl).isEqualTo(Duration.ofSeconds(30));
|
||||||
|
assertThat(region.lookup("tenant-1:missing"))
|
||||||
|
.isEqualTo(new CacheLookup.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void distinguishesMissIncompatibleEnvelopeAndProviderFailure() {
|
||||||
|
assertThat(region.lookup("absent"))
|
||||||
|
.isEqualTo(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT));
|
||||||
|
|
||||||
|
commands.value = new byte[] {0, 1, 2};
|
||||||
|
assertThat(region.lookup("invalid"))
|
||||||
|
.isEqualTo(
|
||||||
|
new CacheLookup.IncompatibleSchema<>(
|
||||||
|
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE,
|
||||||
|
CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD));
|
||||||
|
|
||||||
|
commands.failure = new IllegalStateException("connection unavailable");
|
||||||
|
assertThatThrownBy(() -> region.lookup("programming-error"))
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("connection unavailable");
|
||||||
|
|
||||||
|
commands.failure =
|
||||||
|
new RedisCommandFailureException(
|
||||||
|
RedisCommandFailureException.Kind.UNAVAILABLE,
|
||||||
|
RedisCommandFailureException.Certainty.NOT_APPLIED,
|
||||||
|
"connection unavailable",
|
||||||
|
null);
|
||||||
|
assertThat(region.lookup("unavailable"))
|
||||||
|
.isEqualTo(
|
||||||
|
new CacheLookup.Unavailable<>(
|
||||||
|
CacheLookup.UnavailabilityReason.UNAVAILABLE,
|
||||||
|
CacheLookup.OperationCertainty.NOT_APPLIED));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusesOpaqueNewerRevisionIntentAndMapsMutationCertainty() {
|
||||||
|
CacheRecordOutcome rejected =
|
||||||
|
region.record(
|
||||||
|
"key",
|
||||||
|
"value",
|
||||||
|
new CacheRecordMetadata(
|
||||||
|
"opaque-revision", CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER));
|
||||||
|
|
||||||
|
assertThat(rejected).isEqualTo(CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY);
|
||||||
|
|
||||||
|
commands.failure =
|
||||||
|
new RedisCommandFailureException(
|
||||||
|
RedisCommandFailureException.Kind.UNAVAILABLE,
|
||||||
|
RedisCommandFailureException.Certainty.INDETERMINATE,
|
||||||
|
"timeout",
|
||||||
|
null);
|
||||||
|
assertThat(
|
||||||
|
region.record(
|
||||||
|
"key", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)))
|
||||||
|
.isEqualTo(CacheRecordOutcome.INDETERMINATE);
|
||||||
|
assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.INDETERMINATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapsKnownPreSendMutationFailureToDegradedUnavailable() {
|
||||||
|
commands.failure =
|
||||||
|
new RedisCommandFailureException(
|
||||||
|
RedisCommandFailureException.Kind.UNAVAILABLE,
|
||||||
|
RedisCommandFailureException.Certainty.NOT_APPLIED,
|
||||||
|
"disconnected",
|
||||||
|
null);
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
region.record(
|
||||||
|
"key", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)))
|
||||||
|
.isEqualTo(CacheRecordOutcome.DEGRADED_UNAVAILABLE);
|
||||||
|
assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.DEGRADED_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidRevisionFutureVersionAndBitCorruptionThroughTypedSchemaResults() {
|
||||||
|
commands.value = rawEnvelope((byte) 1, "r".repeat(129), "value");
|
||||||
|
assertThat(region.lookup("invalid-revision"))
|
||||||
|
.isEqualTo(
|
||||||
|
new CacheLookup.IncompatibleSchema<>(
|
||||||
|
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE,
|
||||||
|
CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD));
|
||||||
|
|
||||||
|
commands.value = rawEnvelope((byte) 2, "revision-1", "value");
|
||||||
|
assertThat(region.lookup("future"))
|
||||||
|
.isEqualTo(
|
||||||
|
new CacheLookup.IncompatibleSchema<>(
|
||||||
|
CacheLookup.SchemaCategory.FUTURE_VERSION, CacheLookup.SchemaPolicy.FAIL_FAST));
|
||||||
|
|
||||||
|
commands.value = RedisCacheEnvelopeCodec.positive("value", "revision-1", 1024);
|
||||||
|
commands.value[commands.value.length - 33] ^= 1;
|
||||||
|
assertThat(region.lookup("corrupt"))
|
||||||
|
.isEqualTo(
|
||||||
|
new CacheLookup.IncompatibleSchema<>(
|
||||||
|
CacheLookup.SchemaCategory.CORRUPT_ENVELOPE,
|
||||||
|
CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidatesExistingAndMissingEntriesSeparately() {
|
||||||
|
region.record("key", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT));
|
||||||
|
|
||||||
|
assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.INVALIDATED);
|
||||||
|
assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.ALREADY_ABSENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeCommands implements RedisBinaryCommands {
|
||||||
|
|
||||||
|
private byte[] lastKey;
|
||||||
|
private byte[] value;
|
||||||
|
private Duration lastTtl;
|
||||||
|
private RuntimeException failure;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] get(byte[] key) {
|
||||||
|
failIfConfigured();
|
||||||
|
return value == null ? null : value.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(byte[] key, byte[] value, Duration timeToLive) {
|
||||||
|
failIfConfigured();
|
||||||
|
lastKey = key.clone();
|
||||||
|
this.value = value.clone();
|
||||||
|
lastTtl = timeToLive;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long delete(byte[] key) {
|
||||||
|
failIfConfigured();
|
||||||
|
if (value == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
value = null;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void failIfConfigured() {
|
||||||
|
if (failure != null) {
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] rawEnvelope(byte version, String revision, String value) {
|
||||||
|
byte[] revisionBytes = revision.getBytes(UTF_8);
|
||||||
|
byte[] valueBytes = value.getBytes(UTF_8);
|
||||||
|
byte[] content =
|
||||||
|
ByteBuffer.allocate(12 + revisionBytes.length + valueBytes.length)
|
||||||
|
.putInt(0x43414348)
|
||||||
|
.put(version)
|
||||||
|
.put((byte) 1)
|
||||||
|
.putShort((short) revisionBytes.length)
|
||||||
|
.putInt(valueBytes.length)
|
||||||
|
.put(revisionBytes)
|
||||||
|
.put(valueBytes)
|
||||||
|
.array();
|
||||||
|
byte[] digest;
|
||||||
|
try {
|
||||||
|
digest = java.security.MessageDigest.getInstance("SHA-256").digest(content);
|
||||||
|
} catch (java.security.NoSuchAlgorithmException exception) {
|
||||||
|
throw new AssertionError(exception);
|
||||||
|
}
|
||||||
|
return ByteBuffer.allocate(content.length + digest.length).put(content).put(digest).array();
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.cache.redis.key;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class RedisKeyBuilderTest {
|
||||||
|
|
||||||
|
private static final byte[] HMAC_SECRET =
|
||||||
|
"test-only-hmac-material-with-at-least-32-bytes".getBytes(UTF_8);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildsNamespacedKeyWithoutLeakingSensitiveComponents() {
|
||||||
|
RedisKeyNamespace namespace =
|
||||||
|
new RedisKeyNamespace("worklog-api", "prod", "cache", "summary", 2, 1, "entry", 256);
|
||||||
|
RedisKeyDigest digest =
|
||||||
|
RedisKeyDigest.sensitive(
|
||||||
|
2,
|
||||||
|
HMAC_SECRET,
|
||||||
|
List.of("tenant@example.com".getBytes(UTF_8), "worklog-42".getBytes(UTF_8)));
|
||||||
|
|
||||||
|
String key = RedisKeyBuilder.build(namespace, digest);
|
||||||
|
|
||||||
|
assertThat(key)
|
||||||
|
.startsWith("ca:worklog-api:prod:cache:summary:hv2:kv1:{")
|
||||||
|
.endsWith(":entry")
|
||||||
|
.doesNotContain("tenant@example.com")
|
||||||
|
.doesNotContain("worklog-42");
|
||||||
|
assertThat(key.chars().filter(character -> character == '{').count()).isEqualTo(1);
|
||||||
|
assertThat(key.chars().filter(character -> character == '}').count()).isEqualTo(1);
|
||||||
|
assertThat(key.getBytes(UTF_8).length).isLessThanOrEqualTo(256);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lengthPrefixedDigestPreventsComponentBoundaryAmbiguity() {
|
||||||
|
RedisKeyDigest first =
|
||||||
|
RedisKeyDigest.sensitive(
|
||||||
|
1, HMAC_SECRET, List.of("ab".getBytes(UTF_8), "c".getBytes(UTF_8)));
|
||||||
|
RedisKeyDigest second =
|
||||||
|
RedisKeyDigest.sensitive(
|
||||||
|
1, HMAC_SECRET, List.of("a".getBytes(UTF_8), "bc".getBytes(UTF_8)));
|
||||||
|
|
||||||
|
assertThat(first.resourceDigest()).isNotEqualTo(second.resourceDigest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDigestVersionMismatchAndOversizedPhysicalKey() {
|
||||||
|
RedisKeyDigest digest = RedisKeyDigest.opaque(1, List.of("id".getBytes(UTF_8)));
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
RedisKeyBuilder.build(
|
||||||
|
new RedisKeyNamespace(
|
||||||
|
"worklog-api", "prod", "cache", "summary", 2, 1, "entry", 256),
|
||||||
|
digest))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("version");
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
RedisKeyBuilder.build(
|
||||||
|
new RedisKeyNamespace(
|
||||||
|
"worklog-api", "prod", "cache", "summary", 1, 1, "entry", 32),
|
||||||
|
digest))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,27 +4,29 @@
|
|||||||
|
|
||||||
- Module ID: `adapter-outbound-fileserver`
|
- Module ID: `adapter-outbound-fileserver`
|
||||||
- Gradle path: `:adapter:outbound:fileserver`
|
- Gradle path: `:adapter:outbound:fileserver`
|
||||||
- Focused test: `./gradlew :adapter:outbound:fileserver:test --console=plain`
|
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:fileserver:test --console=plain`
|
||||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||||
|
|
||||||
Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) adapter implementing
|
Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) adapter implementing
|
||||||
`dev.caskeleton.application.fileexport.FileExportPort` (application-core). Design rationale lives in
|
`dev.caskeleton.application.filepublication.FilePublicationPort` (application-core). The legacy
|
||||||
[README.md](README.md).
|
`FileExportPort` remains temporarily for compatibility. Design rationale lives in [README.md](README.md).
|
||||||
|
|
||||||
## Responsibility
|
## Responsibility
|
||||||
|
|
||||||
- Export tabular data as CSV files behind `FileExportPort`, written under
|
- Publish typed tabular data through a bounded producer/sink contract behind
|
||||||
`ca-skeleton.fileserver.base-directory` (stand-in for NFS/SFTP). Single implementation
|
`FilePublicationPort`.
|
||||||
(`FilesystemCsvExportAdapter`); pure JDK filesystem IO, no external service.
|
- Own CSV encoding, schema validation, formula policy, staging, checksum/counts, file force, and
|
||||||
- Opt-in: `FileExportConfig` gates the single `FileExportPort` bean with
|
local exclusive-publication semantics.
|
||||||
`@ConditionalOnProperty(ca-skeleton.fileserver.enabled=true)`, default off. The adapter is a plain
|
- Return opaque references and explicit publication/durability guarantees; do not expose paths.
|
||||||
class; the config assembles it as a bean.
|
- Opt-in: `FileExportConfig` gates publication with
|
||||||
|
`ca-skeleton.fileserver.enabled=true`; the legacy bean additionally requires
|
||||||
|
`ca-skeleton.fileserver.legacy-enabled=true` and a separate root. Both default off.
|
||||||
|
|
||||||
## Allowed
|
## Allowed
|
||||||
|
|
||||||
- Project deps: `:application-core`, `:shared-contract` — SSOT is the
|
- Project deps: `:application-core`, `:shared-contract` — SSOT is the
|
||||||
`adapter-outbound-fileserver` entry in `.harness/project/modules.yaml`; `src/build.gradle`
|
`adapter-outbound-fileserver` entry in `src/config/architecture/modules.json`; `src/build.gradle`
|
||||||
enforces it. No
|
enforces it. No
|
||||||
`:domain-core`, no sibling adapters.
|
`:domain-core`, no sibling adapters.
|
||||||
- External: NONE (pure filesystem). `spring-boot-starter`, `spring-boot-configuration-processor`
|
- External: NONE (pure filesystem). `spring-boot-starter`, `spring-boot-configuration-processor`
|
||||||
@@ -34,14 +36,18 @@ Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) ad
|
|||||||
|
|
||||||
- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio`
|
- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio`
|
||||||
(ArchUnit `OUTBOUND_ADAPTERS_*` family rules).
|
(ArchUnit `OUTBOUND_ADAPTERS_*` family rules).
|
||||||
- Leaking a framework/domain type across `FileExportPort` — the port takes/returns only `String` /
|
- Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`.
|
||||||
`List<String>` / `List<List<String>>` / `ExportedFile`.
|
- Advertising local R1 as crash-recoverable R2. Durable operation journal, reconciliation, SFTP,
|
||||||
|
and NFS/HA evidence are not fully implemented. The local journal only supports single-node
|
||||||
|
terminal restoration and sealed-artifact resume; it is not cross-node fencing or R2 evidence.
|
||||||
|
- Adding a second production provider without an explicit selector and startup ambiguity tests.
|
||||||
- Fully-qualified inline type references; more than one public top-level type per file.
|
- Fully-qualified inline type references; more than one public top-level type per file.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`FilesystemCsvExportAdapterTest` (temp-dir CSV write/verify: header + rows, RFC-4180 escaping,
|
`FilePublicationContractTest`, `LocalFilePublicationAdapterTest`,
|
||||||
null-field, overwrite, path-traversal + blank-name rejection).
|
`LocalPublicationJournalTest`, `LocalFilePublicationRecoveryTest`, `FilePublicationConfigTest`, and
|
||||||
|
the legacy `FilesystemCsvExportAdapterTest`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
|
|||||||
@@ -1,62 +1,92 @@
|
|||||||
# adapter:outbound:fileserver — design-decision reference
|
# adapter:outbound:fileserver — design-decision reference
|
||||||
|
|
||||||
File-server export outbound (driven) adapter. Package root:
|
File-server publication outbound (driven) adapter. Package root:
|
||||||
`dev.caskeleton.adapter.outbound.fileserver`. Implements the `application-core` port
|
`dev.caskeleton.adapter.outbound.fileserver`. It implements the framework-free
|
||||||
`dev.caskeleton.application.fileexport.FileExportPort` behind an opt-in `@ConditionalOnProperty`
|
`application-core` `FilePublicationPort` and temporarily retains the legacy `FileExportPort`.
|
||||||
selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient /
|
Publication and the legacy compatibility port have separate opt-in selectors.
|
||||||
objectstorage).
|
|
||||||
|
|
||||||
The allowed/forbidden dependency policy is owned by `src/build.gradle`'s
|
The allowed/forbidden dependency policy is owned by `src/build.gradle`'s
|
||||||
`allowedProjectDependencies['adapter:outbound:fileserver']` (SSOT). Module rules live in
|
`allowedProjectDependencies['adapter:outbound:fileserver']` (SSOT). Module rules live in
|
||||||
[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code
|
[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code
|
||||||
comments.
|
comments.
|
||||||
|
|
||||||
## Module overview
|
## Implemented capability
|
||||||
|
|
||||||
An **opt-in** file-export adapter placed behind an application-core port. A single
|
`LocalFilePublicationAdapter` is a local-filesystem R1 provider. The application supplies a typed
|
||||||
`FilesystemCsvExportAdapter` writes CSV files under `ca-skeleton.fileserver.base-directory` — a
|
schema and streams rows once through a producer/sink callback. The adapter encodes each row without
|
||||||
stand-in for an NFS mount, shared file server, or SFTP drop. There is no external service and no
|
materializing the whole export, enforces row/encoded-byte/per-cell limits, applies the configured
|
||||||
external dependency (pure JDK filesystem IO), so the local profile just works and the lockfile only
|
spreadsheet-formula policy, computes SHA-256 and counts, forces the staged file, and publishes it
|
||||||
pins the shared Spring Boot / tooling graph.
|
with an exclusive atomic hard-link create. After publication it forces both staging and final
|
||||||
|
directories before recording the terminal journal. Its receipt contains an opaque reference rather than a
|
||||||
|
server path. A private, forced operation journal records request fingerprints and `WRITING`, `SEALED`,
|
||||||
|
and `PUBLISHED` state. On a single local filesystem, a restarted adapter can restore a verified
|
||||||
|
terminal receipt or finish a verified sealed staging artifact without invoking the producer again.
|
||||||
|
A sealed journal plus a verified final target reconstructs the only supported hard-link protocol
|
||||||
|
as `UNIQUE_ATOMIC_CREATE` after re-forcing the final directory.
|
||||||
|
Corrupt/unreadable operation state is exposed only as provider-neutral
|
||||||
|
`PUBLISH_INDETERMINATE`, never as an adapter-internal exception.
|
||||||
|
The private control directory and journal shards reject symbolic links before read/write so a
|
||||||
|
pre-existing internal link cannot redirect journal bytes outside the configured base.
|
||||||
|
Once a `SEALED` record exists, publish conflicts and unsupported atomic publication preserve the
|
||||||
|
verified staging artifact for explicit retry/reconciliation instead of deleting the only recovery
|
||||||
|
evidence.
|
||||||
|
Operation-scoped JVM and OS file locks serialize cooperating callers on the same local filesystem.
|
||||||
|
|
||||||
Selector: `ca-skeleton.fileserver.enabled=true` (default `false`). Unlike objectstorage there is a
|
Selector: `ca-skeleton.fileserver.enabled=true` (default `false`) enables only the new
|
||||||
single implementation, so no backend switch is needed; the `enabled` flag keeps the module from
|
`FilePublicationPort`. The overwrite-capable compatibility port additionally requires
|
||||||
activating unexpectedly when merely present on the classpath. `FileExportConfig` gates the single
|
`ca-skeleton.fileserver.legacy-enabled=true` and writes under its own legacy root. This module is not
|
||||||
`FileExportPort` bean on that flag.
|
currently a default `app-bootstrap` dependency, so a consuming application must intentionally add
|
||||||
|
the leaf as well as enable it.
|
||||||
|
|
||||||
## The port contract (framework/domain-neutral)
|
## Publication contract
|
||||||
|
|
||||||
`FileExportPort` is a minimal, domain-neutral surface:
|
The current contract is:
|
||||||
|
|
||||||
- `ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows)`.
|
- `FilePublishReceipt publish(FilePublishRequest, TabularRowProducer)`.
|
||||||
|
|
||||||
The caller supplies a bare file name, an optional header row, and the data rows as lists of
|
The request uses `FileDestinationId`, `FilePublishOperationId`, `LogicalFileName`,
|
||||||
already-stringified field values. The adapter owns file placement, RFC-4180 escaping, and byte
|
`SourceRevision`, and `ExportSchema`; it has no `Path`, `File`, Spring, stream, or provider type.
|
||||||
encoding, and returns an `ExportedFile` receipt (`fileName`, absolute `path`, `byteSize`,
|
`TabularCell` preserves value types until encoding. The producer writes rows to a bounded sink and
|
||||||
`rowCount`). No framework or domain type crosses the port — the application layer stays decoupled
|
can call `checkpoint()` for cooperative interruption checks.
|
||||||
from the CSV format and the destination filesystem. A fork that needs a real domain export maps its
|
|
||||||
rows to `List<List<String>>` at the call site (or adds a typed convenience method in its own layer).
|
|
||||||
|
|
||||||
## CSV escaping
|
`FileExportPort.exportCsv(...)` remains for compatibility only. It materializes all rows, writes
|
||||||
|
directly to a separate final-path root, permits overwrite, accepts only a bare file name, and
|
||||||
|
returns an absolute path. It must not be used as
|
||||||
|
R2 durability or cluster-safety evidence.
|
||||||
|
|
||||||
Every field is escaped per RFC-4180: a field containing a comma, double-quote, carriage return, or
|
## Settings
|
||||||
line feed is wrapped in double-quotes with embedded quotes doubled. A `null` field is written as an
|
|
||||||
empty field. Rows are separated by `\n` and the file is UTF-8 encoded. Overwriting an existing file
|
|
||||||
at the same name replaces it.
|
|
||||||
|
|
||||||
## IO-failure handling
|
- `ca-skeleton.fileserver.enabled=false`
|
||||||
|
- `ca-skeleton.fileserver.legacy-enabled=false`
|
||||||
|
- `ca-skeleton.fileserver.base-directory=./.data/fileserver`
|
||||||
|
- `ca-skeleton.fileserver.legacy-base-directory=./.data/fileserver-legacy`
|
||||||
|
- `ca-skeleton.fileserver.destination-id=local-export`
|
||||||
|
- `ca-skeleton.fileserver.maximum-rows=1000000`
|
||||||
|
- `ca-skeleton.fileserver.maximum-encoded-bytes=1073741824`
|
||||||
|
|
||||||
Filesystem IO failures are wrapped in the shared-contract `DependencyFailureException`
|
The provider always fails closed if the filesystem cannot supply exclusive hard-link creation.
|
||||||
(`dependencyName="fileserver"`) so a fork's web error handler classifies them uniformly with the
|
There is no copy-to-final or overwrite-capable rename fallback.
|
||||||
other outbound dependencies. A blank file name or one that escapes the base directory (path
|
|
||||||
traversal) is `IllegalArgumentException` (a caller bug, not a dependency failure) — the adapter
|
## Guarantee boundary
|
||||||
normalises the resolved path and checks it still starts with the base directory.
|
|
||||||
|
This remains local R1, not Fileserver R2. It now provides single-node request fingerprinting,
|
||||||
|
forced operation-journal replacement, terminal receipt restoration, and bounded sealed-artifact
|
||||||
|
reconciliation. It does not yet provide cross-node fencing, exhaustive crash-point qualification,
|
||||||
|
reference/manifest indexes, background reconciliation/reaping, SFTP, NFS mount identity,
|
||||||
|
multi-node cleanup, or quota reservation. See
|
||||||
|
`docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md` for the remaining
|
||||||
|
phases.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `FilesystemCsvExportAdapterTest` — `@TempDir` write/verify: header + rows, CSV escaping of a field
|
- `FilePublicationContractTest`: framework-free values and invalid contract inputs.
|
||||||
containing a comma / quote / newline, null-field handling, header-only and headerless exports,
|
- `LocalFilePublicationAdapterTest`: streaming, limits, type checks, formula mitigation, receipt,
|
||||||
overwrite, and path-traversal + blank-name rejection.
|
publication, and staging cleanup.
|
||||||
|
- `LocalPublicationJournalTest`: canonical request fingerprint and strict journal integrity.
|
||||||
|
- `LocalFilePublicationRecoveryTest`: restart receipt restoration, sealed resume, conflict, and
|
||||||
|
artifact-integrity handling.
|
||||||
|
- `FilePublicationConfigTest`: opt-in binding and both port beans.
|
||||||
|
- `FilesystemCsvExportAdapterTest`: legacy compatibility path.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user