feat: add production capability foundations

This commit is contained in:
donghyeon-ka
2026-07-31 23:50:44 +09:00
parent b3add0162d
commit 567422f2e5
757 changed files with 132385 additions and 2146 deletions
+7
View File
@@ -101,6 +101,13 @@ gates:
workflow: ci-quality-gates.yml workflow: ci-quality-gates.yml
job: gate-matrix-lint job: gate-matrix-lint
execution: job execution: job
- id: redis-standalone
release_blocking: true
mechanism: workflow-job
ref: redis-standalone
workflow: ci-quality-gates.yml
job: redis-standalone
execution: job
- id: quality-release-gate - id: quality-release-gate
release_blocking: true release_blocking: true
mechanism: workflow-job mechanism: workflow-job
+1 -1
View File
@@ -5,7 +5,7 @@ readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)" readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)" readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
readonly EXPECTED_GATE_COUNT=19 readonly EXPECTED_GATE_COUNT=20
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2 printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2
+30 -1
View File
@@ -70,6 +70,33 @@ jobs:
- name: Verify the gate matrix against the repository - name: Verify the gate matrix against the repository
run: bash .github/scripts/verify-gate-matrix.sh run: bash .github/scripts/verify-gate-matrix.sh
redis-standalone:
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 standalone Redis policy, provider, and composition contracts
working-directory: src
run: >-
./gradlew
:application-core:redisPolicyContractTest
:shared-contract:edgeRateLimitContractTest
:adapter:outbound:cache-redis:check
:app-bootstrap:redisCompositionTest
verifyCleanArchitectureDependencies
verifyEnvKeys
verifyPublicPathSnapshot
verifyConfigurationPropertiesProcessor
--no-daemon --stacktrace
# Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check. # Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check.
quarantine: quarantine:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -94,6 +121,7 @@ jobs:
- quality-gates - quality-gates
- sample-off - sample-off
- gate-matrix-lint - gate-matrix-lint
- redis-standalone
if: always() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -102,9 +130,10 @@ jobs:
QUALITY_RESULT: ${{ needs.quality-gates.result }} QUALITY_RESULT: ${{ needs.quality-gates.result }}
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }} SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }} MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
REDIS_RESULT: ${{ needs.redis-standalone.result }}
run: | run: |
set -euo pipefail set -euo pipefail
for result in "${QUALITY_RESULT}" "${SAMPLE_OFF_RESULT}" "${MATRIX_RESULT}"; do for result in "${QUALITY_RESULT}" "${SAMPLE_OFF_RESULT}" "${MATRIX_RESULT}" "${REDIS_RESULT}"; do
if [[ "${result}" != "success" ]]; then if [[ "${result}" != "success" ]]; then
echo "::error::release-gate: required job result was ${result}" echo "::error::release-gate: required job result was ${result}"
exit 1 exit 1
@@ -0,0 +1,375 @@
name: redis-production-readiness
on:
schedule:
- cron: "23 18 * * *"
workflow_dispatch:
push:
tags:
- "v*-rc.*"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
resolve-redis-readiness:
runs-on: ubuntu-latest
outputs:
selected: ${{ steps.resolve.outputs.selected }}
candidates: ${{ steps.resolve.outputs.candidates }}
selected_count: ${{ steps.resolve.outputs.selected_count }}
sentinel_required: ${{ steps.resolve.outputs.sentinel_required }}
cluster_required: ${{ steps.resolve.outputs.cluster_required }}
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
- name: Generate the strict checked-in readiness control artifact
working-directory: src
run: ./gradlew writeRedisCiMatrix --no-daemon --stacktrace
- id: resolve
name: Transport the generated matrix to job outputs
shell: python
run: |
import json
import os
from pathlib import Path
matrix_path = Path(
"src/build/redis-evidence/control/redis-readiness-matrix.json"
)
matrix = json.loads(matrix_path.read_text(encoding="utf-8"))
if matrix["releaseQualification"] != "NOT_CLAIMED":
raise SystemExit("resolver control artifact must not claim release qualification")
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream:
stream.write(
"selected="
+ json.dumps(matrix["selected"], separators=(",", ":"))
+ "\n"
)
stream.write(
"candidates="
+ json.dumps(
matrix["implementedCandidates"], separators=(",", ":")
)
+ "\n"
)
stream.write(f"selected_count={matrix['selectedCount']}\n")
stream.write(
"sentinel_required="
+ str(matrix["topologyJobs"]["sentinel"]).lower()
+ "\n"
)
stream.write(
"cluster_required="
+ str(matrix["topologyJobs"]["cluster"]).lower()
+ "\n"
)
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-readiness-control
path: src/build/redis-evidence/control
if-no-files-found: error
retention-days: 30
redis-security:
needs: resolve-redis-readiness
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
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisSecurityTest --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-security-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
redis-sentinel:
needs: resolve-redis-readiness
if: ${{ needs.resolve-redis-readiness.outputs.sentinel_required == 'true' }}
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
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisSentinelTest --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-sentinel-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
redis-cluster:
needs: resolve-redis-readiness
if: ${{ needs.resolve-redis-readiness.outputs.cluster_required == 'true' }}
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
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisClusterTest --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-cluster-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
redis-fault:
needs: resolve-redis-readiness
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
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisFaultTest --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-fault-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
redis-compatibility:
needs: resolve-redis-readiness
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
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisCompatibilityTest --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-compatibility-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
selected-card-readiness:
needs: resolve-redis-readiness
if: ${{ needs.resolve-redis-readiness.outputs.selected_count != '0' }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.resolve-redis-readiness.outputs.selected) }}
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
- id: redis-tests
working-directory: src
run: ./gradlew ${{ matrix.readinessTask }} --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-selected-${{ matrix.cardId }}
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 30
redis-all-candidates:
needs: resolve-redis-readiness
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
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
- id: redis-tests
working-directory: src
run: ./gradlew redisAllImplementedCandidates --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-all-candidates-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
redis-production-readiness:
needs:
- resolve-redis-readiness
- selected-card-readiness
if: ${{ always() && needs.resolve-redis-readiness.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Require the exact selected matrix result
shell: python
env:
SELECTED_COUNT: ${{ needs.resolve-redis-readiness.outputs.selected_count }}
SELECTED_JOB_RESULT: ${{ needs.selected-card-readiness.result }}
run: |
import os
selected_count_text = os.environ["SELECTED_COUNT"]
selected_job_result = os.environ["SELECTED_JOB_RESULT"]
if not selected_count_text.isdecimal():
raise SystemExit("selected_count must be a non-negative integer")
selected_count = int(selected_count_text)
expected_result = "skipped" if selected_count == 0 else "success"
if selected_job_result != expected_result:
raise SystemExit(
"selected-card-readiness result mismatch: "
f"count={selected_count}, expected={expected_result}, "
f"actual={selected_job_result}"
)
- 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
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
with:
name: redis-readiness-control
path: ${{ runner.temp }}/redis-readiness/control
- if: >-
${{
needs.resolve-redis-readiness.outputs.selected_count != '0'
&& needs.selected-card-readiness.result == 'success'
}}
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
with:
pattern: redis-selected-*
path: ${{ runner.temp }}/redis-readiness/selected
- name: Record the downloaded selected artifact inventory
shell: python
env:
GITHUB_RUN_ID: ${{ github.run_id }}
SELECTED_JSON: ${{ needs.resolve-redis-readiness.outputs.selected }}
SELECTED_COUNT: ${{ needs.resolve-redis-readiness.outputs.selected_count }}
SELECTED_JOB_RESULT: ${{ needs.selected-card-readiness.result }}
REDIS_SELECTED_DIRECTORY: ${{ runner.temp }}/redis-readiness/selected
REDIS_CI_RESULT_FILE: ${{ runner.temp }}/redis-readiness/redis-ci-result.json
run: |
import json
import os
from pathlib import Path
selected = json.loads(os.environ["SELECTED_JSON"])
if not isinstance(selected, list):
raise SystemExit("selected matrix must be a JSON array")
expected_names = sorted(
"redis-selected-" + entry["cardId"] for entry in selected
)
if len(expected_names) != int(os.environ["SELECTED_COUNT"]):
raise SystemExit("selected_count does not match the selected matrix")
if len(expected_names) != len(set(expected_names)):
raise SystemExit("selected matrix contains duplicate artifact names")
selected_directory = Path(os.environ["REDIS_SELECTED_DIRECTORY"])
actual_names = (
sorted(path.name for path in selected_directory.iterdir() if path.is_dir())
if selected_directory.is_dir()
else []
)
if actual_names != expected_names:
raise SystemExit(
"downloaded selected artifact inventory mismatch: "
f"expected={expected_names}, actual={actual_names}"
)
result = {
"schemaVersion": 1,
"runId": os.environ["GITHUB_RUN_ID"],
"selectedCount": len(expected_names),
"selectedJobResult": os.environ["SELECTED_JOB_RESULT"],
"selectedArtifactNames": actual_names,
}
result_path = Path(os.environ["REDIS_CI_RESULT_FILE"])
result_path.parent.mkdir(parents=True, exist_ok=True)
result_path.write_text(
json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n",
encoding="utf-8",
)
- if: ${{ needs.resolve-redis-readiness.outputs.selected_count == '0' }}
working-directory: src
run: >-
./gradlew verifyRedisSelectedEvidenceArtifacts redisProductionReadiness
-PredisControlDirectory=${{ runner.temp }}/redis-readiness/control
-PredisCiResultFile=${{ runner.temp }}/redis-readiness/redis-ci-result.json
--no-daemon --stacktrace
- if: ${{ needs.resolve-redis-readiness.outputs.selected_count != '0' }}
working-directory: src
run: >-
./gradlew verifyRedisSelectedEvidenceArtifacts redisProductionReadiness
-PredisControlDirectory=${{ runner.temp }}/redis-readiness/control
-PredisEvidenceDirectory=${{ runner.temp }}/redis-readiness/selected
-PredisCiResultFile=${{ runner.temp }}/redis-readiness/redis-ci-result.json
--no-daemon --stacktrace
+2
View File
@@ -0,0 +1,2 @@
.vscode/
src/**/bin/
File diff suppressed because it is too large Load Diff
+305
View File
@@ -433,6 +433,311 @@ metrics:
compatibility_impact: additive compatibility_impact: additive
required_test: contract-verification:metrics-cardinality required_test: contract-verification:metrics-cardinality
# source: redis-production-capability — optional bounded cache-only L1
- name: cache.local.requests.total
type: counter
unit: total
tags:
- name: cache_name
cardinality_limit: 50
- name: result
cardinality_limit: 4
allowed_values: [hit, miss, error, bypass]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p3: "bypass or error rate above baseline for 15m"
owner_branch: redis-production-capability
log_field_mapping: [cache_name, result]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability — local stale-age bound
- name: cache.local.entry.age.seconds
type: timer
unit: seconds
tags:
- name: cache_name
cardinality_limit: 50
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p3: "p99 approaches configured local TTL for 30m"
owner_branch: redis-production-capability
log_field_mapping: [cache_name]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability — bounded invalidation and generation reconciliation
- name: cache.local.maintenance.total
type: counter
unit: total
tags:
- name: cache_name
cardinality_limit: 50
- name: event
cardinality_limit: 14
allowed_values:
- evict_cardinality
- evict_weight
- evict_ttl
- evict_invalidation
- flush_invalidation
- reconcile_generation_changed
- reconcile_unchanged
- reconcile_error
- subscriber_disconnected
- subscriber_overflow
- subscriber_malformed
- subscriber_publish_success
- subscriber_publish_error
- other
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "reconcile_error, subscriber_overflow, or sustained disconnects for 5m"
owner_branch: redis-production-capability
log_field_mapping: [cache_name, event]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — closed semantic operation outcomes
- name: redis.capability.operations.total
type: counter
unit: total
tags:
- name: capability
cardinality_limit: 6
allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session, runtime]
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: operation
cardinality_limit: 24
allowed_values:
- lookup
- record
- invalidate
- refresh_claim
- refresh_release
- rate_evaluate
- idempotency_claim
- idempotency_start
- idempotency_renew
- idempotency_complete
- idempotency_fail
- idempotency_release
- idempotency_inspect
- lease_acquire
- lease_inspect
- lease_renew
- lease_release
- session_create
- session_inspect
- session_save
- session_touch
- session_revoke
- session_rotate
- route_command
- name: redis_outcome
cardinality_limit: 15
allowed_values:
- success
- hit
- miss
- denied
- contended
- conflict
- incompatible
- unavailable
- overloaded
- closed
- indeterminate
- stale
- skipped
- tombstoned
- absolute_expired
- name: certainty
cardinality_limit: 3
allowed_values: [definite, not_applied, indeterminate]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "required coordination/session unavailable or indeterminate mutation sustained for 2m"
p2: "optional cache unavailable or overloaded above baseline for 5m"
owner_branch: redis-production-capability
log_field_mapping: [capability, role, operation, redis_outcome, certainty]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — monotonic semantic operation duration
- name: redis.capability.duration.seconds
type: timer
unit: seconds
tags:
- name: capability
cardinality_limit: 6
allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session, runtime]
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: operation
cardinality_limit: 24
allowed_values:
- lookup
- record
- invalidate
- refresh_claim
- refresh_release
- rate_evaluate
- idempotency_claim
- idempotency_start
- idempotency_renew
- idempotency_complete
- idempotency_fail
- idempotency_release
- idempotency_inspect
- lease_acquire
- lease_inspect
- lease_renew
- lease_release
- session_create
- session_inspect
- session_save
- session_touch
- session_revoke
- session_rotate
- route_command
- name: redis_outcome
cardinality_limit: 15
allowed_values:
- success
- hit
- miss
- denied
- contended
- conflict
- incompatible
- unavailable
- overloaded
- closed
- indeterminate
- stale
- skipped
- tombstoned
- absolute_expired
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p2: "p99 approaches the configured command or caller deadline for 10m"
owner_branch: redis-production-capability
log_field_mapping: [capability, role, operation, redis_outcome]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — admission rejected before command ownership
- name: redis.capability.admission.rejected.total
type: counter
unit: total
tags:
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: admission
cardinality_limit: 2
allowed_values: [rejected_saturated, rejected_closed]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "required role rejection sustained above zero for 2m"
p2: "optional cache saturation sustained for 5m"
owner_branch: redis-production-capability
log_field_mapping: [role, admission]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — bounded admitted command count observation
- name: redis.capability.inflight.total
type: gauge
unit: total
tags:
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: state
cardinality_limit: 3
allowed_values: [idle, active, saturated]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "saturated series remains nonzero for 5m"
owner_branch: redis-production-capability
log_field_mapping: [role, state]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — observations of exact sanitized RoleHealth
- name: redis.capability.readiness.total
type: counter
unit: total
tags:
- name: capability
cardinality_limit: 5
allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session]
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: state
cardinality_limit: 3
allowed_values: [available, unavailable, overloaded]
- name: reason
cardinality_limit: 11
allowed_values:
- command_unavailable
- route_closed
- semantic_probe_succeeded
- semantic_read_write_failed
- semantic_program_acl_denied
- semantic_program_failed
- server_version_unsupported
- semantic_probe_in_progress
- semantic_observation_stale
- command_saturated
- recent_command_failure
- name: requirement
cardinality_limit: 2
allowed_values: [optional, required]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "required coordination/session unavailable for 2m"
p2: "optional cache unavailable or overloaded for 5m"
owner_branch: redis-production-capability
log_field_mapping: [capability, role, state, reason, requirement]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — bounded router shutdown drain result
- name: redis.capability.lifecycle.drain.total
type: counter
unit: total
tags:
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: drain_outcome
cardinality_limit: 3
allowed_values: [drained, forced_after_timeout, interrupted]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "required role forced_after_timeout or interrupted during shutdown"
p2: "optional cache forced close during shutdown"
owner_branch: redis-production-capability
log_field_mapping: [role, drain_outcome]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Log appender === # === Log appender ===
# source: feature-log-management-contract — Sampling Policy (final) # source: feature-log-management-contract — Sampling Policy (final)
# "async appender overflow default: drop oldest INFO/DEBUG with counter metric (log.appender.dropped.total)" # "async appender overflow default: drop oldest INFO/DEBUG with counter metric (log.appender.dropped.total)"
+108
View File
@@ -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_TRUST_PEM
# Public CA bundle content, but integrity-sensitive and supplied by the mounted environment.
classification: sensitive-config
source: mounted-env
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:redis-trust-reference-no-leak
- name: APP_CACHE_REDIS_KEY_HMAC_SECRET - name: APP_CACHE_REDIS_KEY_HMAC_SECRET
# Stable cache-key HMAC material. It is distinct from the Redis authentication credential. # Stable cache-key HMAC material. It is distinct from the Redis authentication credential.
classification: secret classification: secret
@@ -101,6 +113,102 @@ secrets:
compatibility_impact: breaking compatibility_impact: breaking
required_test: secrets-contract:redis-key-hmac-no-leak required_test: secrets-contract:redis-key-hmac-no-leak
- name: APP_RATE_LIMIT_REDIS_PASSWORD
# Dedicated coordination-role Redis credential. It is never inherited from cache Redis.
classification: secret
source: secret-manager
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-distributed-rate-limit
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:rate-limit-redis-password-no-leak
- name: APP_RATE_LIMIT_REDIS_TRUST_PEM
# Coordination-role CA bundle content; integrity-sensitive but not credential material.
classification: sensitive-config
source: mounted-env
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:rate-limit-redis-trust-reference-no-leak
- name: APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET
# Stable private-key derivation material for rate-limit subjects and policy revisions.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-distributed-rate-limit
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:rate-limit-redis-key-hmac-no-leak
- name: APP_SESSION_REDIS_PASSWORD
# Dedicated session-role ACL credential; never shared implicitly with cache or coordination.
classification: secret
source: secret-manager
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:session-redis-password-no-leak
- name: APP_SESSION_REDIS_TRUST_PEM
# Session-role CA bundle content; integrity-sensitive but not credential material.
classification: sensitive-config
source: mounted-env
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:session-redis-trust-reference-no-leak
- name: APP_SESSION_REDIS_KEY_HMAC_SECRET
# Stable private derivation material for pseudonymous Redis session keys.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:session-redis-key-hmac-no-leak
- name: APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET
# Owner-safe request-replay keys must not expose tenant/scope/request identifiers.
classification: secret
source: secret-manager
rotation_policy: cold-cutover-restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability-completion
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:idempotency-redis-key-hmac-no-leak
- name: APP_LEASE_REDIS_KEY_HMAC_SECRET
# Efficiency-lease resource and owner scopes use a dedicated derivation key.
classification: secret
source: secret-manager
rotation_policy: cold-cutover-restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability-completion
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:lease-redis-key-hmac-no-leak
- name: APP_PRIVACY_PSEUDONYMIZATION_SALT - 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일.
+222
View File
@@ -0,0 +1,222 @@
---
title: Runbook — Redis capability incident
category: TRANSIENT_DEPENDENCY
error_codes: []
severity: P1
owner: oncall
last_updated: 2026-07-29
status: active
---
# Runbook: Redis capability incident (`runbook://redis/capability-incident`)
이 runbook은 Redis 전체를 하나의 상태로 취급하지 않는다. 먼저 영향받은 capability와 role을
식별한다.
| Role | Capability | 기본 안전 결정 |
| --- | --- | --- |
| `CACHE` | cache, cache refresh soft lease | source fallback 예산 안에서 degraded serving 허용 |
| `COORDINATION` | edge rate limit, request-replay idempotency, efficiency lease | 새 mutation/claim을 fail closed하고 결과 불확실성을 보존 |
| `SESSION` | Redis session | 인증을 fail open하지 않고 재인증 또는 503으로 전환 |
Redis liveness 실패만으로 pod를 재시작하지 않는다. 재시작 폭주는 reconnect와 source fallback
부하를 키울 수 있다.
## Detection
- readiness detail에서 affected role과 `required` 여부를 확인한다. endpoint, key, token, secret
reference는 detail에 포함되면 안 된다.
- semantic reason을 구분한다: read/write failure, program ACL denial, unsupported server
version, program failure, command saturation, recent command failure, probe-in-progress,
stale observation, closed route, command unavailable. `semanticObservedAt`,
`semanticAgeMillis`, `semanticStale`를 함께 확인한다. PING 성공만으로 role이 ready라는 뜻은
아니다.
- `evictionValidation=CONFIGURED_EXPECTATION_ONLY`
`externalEvictionAttestation=INCOMPLETE`는 effective server policy가 증명되지 않았다는
뜻이다. 이를 정상 attestation으로 해석하지 않는다.
- `redis.capability.operations.total``redis.capability.duration.seconds`에서 affected
capability/role/operation의 실제 반환 outcome을 확인한다. mutation의
`certainty=indeterminate`는 timeout이나 연결 끊김을 미실행 증거로 바꾸지 않는다.
- `redis.capability.admission.rejected.total`에서 `rejected_saturated`
`rejected_closed`를 구분하고, `redis.capability.inflight.total`의 같은 role에 대해 현재 0이
아닌 state를 확인한다. in-flight gauge는 bounded command count이며 byte 수나 queue depth가
아니다.
- `redis.capability.readiness.total`은 현재 상태 gauge가 아니라 exact sanitized
`RoleHealth` 관측 횟수다. 최신 health detail의 state/reason/requirement와 함께 해석한다.
optional cache의 degraded serving과 required coordination/session의 fail-closed 결정을
같은 availability 의미로 합치지 않는다.
- 종료 시 `redis.capability.lifecycle.drain.total`에서 `drained`,
`forced_after_timeout`, `interrupted`를 구분한다. repeated close는 새 drain을 시작하거나
중복 관측을 만들지 않는다.
- reconnect, cache source-load, session repository error 지표의 변화를 함께 본다.
- Redis server 측에서는 memory/eviction, rejected clients, replication link/lag,
persistence error, Cluster coverage를 operator dashboard에서 확인한다.
- `NOSCRIPT`, result-schema mismatch, ACL denial, TLS/auth failure, OOM, timeout을 서로 다른
incident category로 분류한다. timeout은 command 미실행 증거가 아니다.
### Observability and lifecycle boundaries
- 여섯 `redis.capability.*` meter의 tag는 닫힌 enum에서만 생성된다. key, subject, session id,
token, endpoint, exception text, script/SHA, value 같은 identity/wire material을 metric이나
ticket에 복사하지 않는다.
- semantic operation 계측은 logical provider가 실제로 반환한 hit/miss/denied/conflict/
unavailable/indeterminate 결과를 기록한다. cache의 `stale`/`skipped`, session의
`tombstoned`/`absolute_expired`도 정상 hit/miss와 분리한다. meter registry, classifier,
monotonic ticker 장애는 command 결과나 원래 exception instance를 바꾸지 않는다.
- route 응답이 설정된 byte/collection bound를 넘으면 동일 logical operation을
`unavailable`로 종료한다. GET/read-only 응답은 `not_applied`, mutation VALUE/MULTI 응답은
서버 실행 여부를 되돌릴 수 없으므로 `indeterminate`다. 앞선 `success` 표본과 이 실패를 두
operation으로 합산하지 않는다.
- Spring 종료의 dependency order는 invalidation subscription 같은 capability dependent를 먼저
닫고, capability bean을 닫은 다음 canonical registry가 router admission을 닫아 in-flight를
bounded drain하고 마지막에 runtime을 닫는 순서다. 종료 중 새 command를 허용하거나 drain
timeout 뒤 무기한 기다리지 않는다.
- 현재 composition에는 active Redis scheduler나 dormant credential-rotation coordinator가 없다.
존재하지 않는 lifecycle coordinator를 복구 절차에서 찾거나 수동 호출하지 않는다.
- 이 meter와 단일-process lifecycle test는 Sentinel/Cluster failover, TLS/ACL 배포 적합성,
k3s multi-node, L1/L2 분산 일관성, distributed session 동작의 qualification 증거가 아니다.
해당 label은 별도 topology/conformance lane의 실제 증거가 있어야 한다.
## Immediate mitigation
1. 새 배포나 credential/program 전환 직후라면 해당 rollout을 중지한다. 이미 실행된 mutation을
무조건 재시도하지 않는다.
2. optional cache만 영향을 받으면 source bulkhead와 stale/source fallback 예산을 확인한 뒤
degraded serving을 유지한다. source가 포화되면 cache miss를 더 많은 source 요청으로
증폭시키지 않는다.
3. rate limit이 불확실하면 정책에 정의된 fail-closed 또는 bounded local-emergency만 사용한다.
local provider를 조용한 primary fallback으로 바꾸지 않는다.
4. idempotency claim/complete 응답이 유실됐으면 같은 operation token으로 inspect/reconcile한다.
record를 삭제하거나 새 owner를 추측하지 않는다.
5. lease 결과가 불확실하면 소유권이 있다고 가정하지 않는다. fencing 없는 efficiency lease를
correctness lock으로 승격하지 않는다.
6. session repository 장애에서는 기존 요청을 인증된 것으로 간주하지 않는다. fail closed 또는
재인증으로 전환하고 JWT와 Redis Session filter를 동시에 활성화하지 않는다.
## Diagnosis
### Connectivity, TLS, ACL
- 배포 설정이 올바른 role을 참조하고 TLS, hostname verification, explicit trust bundle, named ACL
user를 사용하는지 확인한다.
- runtime identity로 `CONFIG`, `KEYS`, `FLUSH*`, arbitrary program deployment를 시도하지
않는다. Catalog digest로 닫힌 recovery 외 ACL 점검은 별도 operator/deployer identity의
`ACL DRYRUN` 또는 동등한 관리 절차로 수행한다.
- runtime readiness identity에는 bounded probe namespace `~ca-health:*`, SET/GET/DEL,
PING/EVALSHA와 catalog recovery에 필요한 SCRIPT LOAD, 그리고 선택 capability manifest의 exact
command set이 필요하다. broad `~*`/`+@all`로 장애를 우회하지 않는다.
- readiness probe는 5초 TTL의 opaque key만 사용한다. `ca-health:*` key가 5초를 넘겨 남는다면
cleanup/expiry 이상으로 분류하되 key나 value를 ticket/log에 복사하지 않는다.
- 기본 semantic cadence는 minimum interval 5초, maximum staleness 15초다. refresh follower는
blocking하지 않는다. maximum staleness를 넘은 관측을 backend 정상으로 해석하지 말고,
probe 부하를 줄이기 위해 interval을 1초 미만으로 낮추지 않는다.
- optional CACHE의 typed temporary connect/PING outage만 dormant degraded startup과
health-triggered reconnect를 허용한다. reconnect 후보는 full semantic qualification 뒤에만
설치된다. auth/TLS/material/version/ACL/schema mismatch를 transient로 재분류하거나 required
role에 같은 fallback을 적용하지 않는다.
- credential rotation 중이라면 new credential 검증, traffic switch, old connection drain,
old credential revoke 순서를 확인한다. secret 값은 ticket, log, shell history에 복사하지 않는다.
### Program or schema
- checked-in program manifest digest와 배포 artifact digest를 대조한다.
- `semantic-capability-acl-v1` contract와 Redis minimum 7.2를 확인한다. 이 프로그램은
Redis Lua API의 `redis.acl_check_cmd`로 선택 capability의 exact command/key 권한을
비변경 방식으로 검사하고 `redis.REDIS_VERSION_NUM`의 explicit >=7.2 gate를 먼저 적용한다.
두 API는 7.0부터 존재하지만 repository support policy minimum은 7.2다.
- `NOSCRIPT`는 bounded `SCRIPT LOAD -> digest verify -> EVALSHA` recovery가 수행됐는지 확인한다.
arbitrary `EVAL`로 우회하지 않는다.
- result-schema/key/codec future version은 장애가 아니라 호환성 위반으로 분류하고 writer rollout을
중지한다.
- `BUSY` 또는 slow program이면 affected capability admission을 줄이고 isolated environment에서만
재현한다. shared Redis에 장시간 script를 추가 실행하지 않는다.
### Memory and eviction
- `CACHE` 배포와 `COORDINATION`/`SESSION` 배포가 물리적으로 분리됐는지 확인한다.
- correctness role에서 eviction이 관측되면 P1이다. 새 write를 중지하고 record loss를 전제로
idempotency/session reconciliation 또는 재인증 범위를 산정한다.
- noeviction OOM은 성공으로 변환하지 않는다. cache write는 degraded/indeterminate, coordination
mutation은 unavailable/indeterminate로 유지한다.
- big key를 찾을 때 production request path에서 `KEYS`나 unbounded collection read를 사용하지
않는다. 승인된 operator job의 bounded `SCAN`/sampling을 사용한다.
### Topology and persistence
- 현재 구현 후보 card의 promotion topology는 readiness registry의 `selected-topology`가 정본이다.
이는 selection 또는 R2 qualification을 뜻하지 않는다. Sentinel/Cluster evidence가 없는
상태에서 standalone 증거를 HA 증거로 재사용하지 않는다.
- Cluster same-slot semantic probe는 해당 hash slot owner 한 노드만 검증한다. 이를 cluster-wide
또는 failover target version/ACL/program 증거로 해석하지 말고, promotion 전에 모든 target을
별도 conformance lane으로 검증한다.
- failover 뒤에는 in-flight mutation의 certainty, primary role, program availability, replication
offset/lag, persistence status를 각각 확인한다.
- restore 후 session/idempotency/lease record를 자동으로 신뢰하지 않는다. security epoch,
tombstone, durable receipt/fencing high-watermark가 필요한 capability는 별도 reconciliation을
수행한다.
### Sentinel failover
1. affected role의 semantic readiness가 unavailable인지 확인하고 단순 PING success로 정상 판정하지
않는다. required coordination/session은 새 mutation admission을 닫는다.
2. 세 Sentinel 중 응답 수와 같은 master에 동의한 수를 확인한다. 2-of-3 동의 전에는 임의 endpoint,
최초 응답 또는 DNS 추측으로 data runtime을 바꾸지 않는다.
3. Sentinel discovery credential/CA와 Redis data credential/CA가 분리되어 있는지 확인한다.
장애 우회를 위해 trust-all, hostname verification off, plaintext 또는 broad ACL을 열지 않는다.
4. election, discovered primary qualification, new runtime install, old runtime admission close/drain의
순서를 확인한다. old runtime을 강제로 닫아야 했다면 그 시점의 mutation을 성공/미실행으로
추정하지 않는다.
5. response-only cut, timeout, disconnect가 있었던 rate/idempotency/session mutation은
`INDETERMINATE`를 보존한다. rate evaluation replay, 같은 idempotency/session operation token의
inspect/reconcile 또는 재인증을 사용하고 blind retry하지 않는다.
6. semantic readiness 복구 전에는 traffic을 정상화하지 않는다. 복구 뒤 old primary의 replica
재합류, replication lag/acknowledgement, program digest, actor runtime generation을 확인한다.
Sentinel은 asynchronous replication의 zero-data-loss나 strong consistency를 보장하지 않는다.
`min-replicas-to-write`, lag bound, replica acknowledgement가 설정돼도 acknowledgement 결과가
불명확한 mutation은 여전히 `INDETERMINATE`다.
### Disposable Multipass k3s qualification safety
qualification lab은 host k3s incident 조치 도구가 아니다. VM exact allowlist는
`ca-redis-lab-server`, `ca-redis-lab-agent-1`, `ca-redis-lab-agent-2`이며 전용 kubeconfig와
`ca-redis-lab` context만 사용한다.
- 시작 전 host context/API/node/CIDR/NodePort와 Multipass inventory fingerprint를 기록한다.
- lab pod/service CIDR `10.52.0.0/16`, `10.53.0.0/16`이 host와 겹치면 생성하지 않는다.
- default kubeconfig를 merge/overwrite하거나 host context에 write command를 실행하지 않는다.
- cleanup은 exact 세 VM만 대상으로 한다. global `multipass purge`, wildcard delete를 사용하지
않는다.
- 성공/실패 뒤 postflight fingerprint와 VM resource 0을 확인한다. local retain-on-failure가
명시적으로 활성화됐으면 보존 이유와 exact inventory를 기록하며 CI에서는 보존하지 않는다.
- 이 한 물리 host의 3 VM 결과를 k3s control-plane HA, physical host/AZ failure 또는
multi-region 증거로 승격하지 않는다.
## Recovery and verification
1. affected role의 connection/auth/TLS와 `ca-health:` SET/GET/cleanup probe가 정상인지
확인한다. probe 잔여 key가 있으면 최대 TTL 5초 뒤 소멸하는지도 확인한다.
2. 선택 capability의 대표 program digest/result schema, semantic ACL contract와 Redis minimum
version 7.2를 재확인한다.
3. capability별 smoke를 수행한다: cache generation guarded write, rate evaluation replay,
idempotency same-operation inspect, lease stale-owner reject, session create/read/logout.
4. queue saturation, indeterminate outcome, source fallback, re-auth 지표가 incident 전 범위로
돌아온 뒤에만 rollout을 재개한다.
5. `CONFIGURED_EXPECTATION_ONLY`인 eviction은 operator/deployer identity의 외부 conformance
job 또는 서명 attestation으로 effective policy를 별도 검증한다. runtime user에 CONFIG/ACL
권한을 추가하지 않는다.
6. production label을 변경하기 전 repository readiness task를 실행한다. Sentinel/Cluster task가
zero-evidence로 실패한다면 topology를 낮춰 표기하거나 실제 evidence를 먼저 추가한다.
7. Sentinel qualification에서는 actual image ID/digest와 fault/election/runtime-swap/readiness
timeline, capability certainty, teardown 결과가 sanitizer/reconciler를 통과했는지 확인한다.
clean committed source와 실제 remote CI가 없으면 `implemented-candidate`,
`releaseQualification=NOT_CLAIMED`를 유지한다.
## Escalation
- `COORDINATION` 또는 `SESSION` required role이 5분 이상 unavailable이면 P1로 Redis/platform,
application on-call을 동시에 호출한다.
- data loss, stale session resurrection, conflicting idempotency completion, duplicate correctness
side effect가 의심되면 security/business owner까지 즉시 확대한다.
- 한 물리 host의 VM 세 개 또는 standalone container 결과를 AZ/host failure 증거로 해석하지
않는다. 그 증거가 필요한 release는 별도 disposable multi-node qualification을 요구한다.
@@ -0,0 +1,882 @@
# Fileserver R2 Control Plane and Provider Selection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox
> (`- [ ]`) syntax for tracking. Repository policy is `human-only`: do not stage, commit, amend, or
> push.
**Goal:** Add an explicit provider-neutral Fileserver R2 control plane and qualify
`local-persistent` as the first provider without making local filesystem the production default.
**Architecture:** `application-core` keeps the existing `FilePublicationPort` and gains only one
provider-neutral achieved-durability value. The fileserver leaf compiles `app.fileserver`
destination/provider settings into an exact registry, routes requests through one port bean, and
coordinates versioned operation, manifest, and reference records. A strict
`local-persistent` provider attests its root before use and advances the durable publication state
machine in forced, recoverable steps.
**Tech Stack:** Java 21, Spring Boot 4 configuration properties/autoconfiguration, JDK NIO/POSIX,
JUnit 5, AssertJ, ApplicationContextRunner, Gradle quality gates.
---
### Task 1: Add the provider-neutral achieved durability
**Files:**
- Modify:
`src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java`
- Modify:
`src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java`
- [x] **Step 1: Write the failing contract test**
Add a test that constructs a receipt with the new achieved value and proves no provider or path type
is introduced:
```java
@Test
void receiptCanReportFileAndDirectorySyncWithoutExposingAProviderType() {
FilePublishReceipt receipt =
receiptWith(DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC);
assertThat(receipt.durabilityGuarantee())
.isEqualTo(DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC);
assertThat(FilePublishReceipt.class.getDeclaredFields())
.allSatisfy(field -> assertThat(field.getType().getName())
.doesNotContain("java.nio.file", "fileserver", "sftp"));
}
```
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain
```
Expected: compilation failure because `FILE_AND_DIRECTORY_SYNC` does not exist.
- [x] **Step 3: Implement the minimum contract change**
Add only this enum member:
```java
public enum DurabilityGuarantee {
PROCESS_LOCAL_SYNC,
FILE_AND_DIRECTORY_SYNC,
PROVIDER_ACK_ONLY
}
```
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
---
### Task 2: Compile exact destination/provider settings with no local fallback
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Settings.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompilerTest.java`
- [x] **Step 1: Write failing exact-binding tests**
Cover:
```java
@Test
void enabledSettingsRequireAnExplicitDestinationAndProvider() {
assertThatThrownBy(() -> FileserverBindingCompiler.compile(enabled(Map.of(), Map.of())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("destination");
}
@Test
void rejectsUnknownOrUnimplementedProviderTypes() {
assertThatThrownBy(() -> compile("shared-mounted"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("local-persistent");
}
@Test
void compilesOnlyAnExactLocalPersistentBinding() {
Map<FileDestinationId, CompiledFileDestination> result =
FileserverBindingCompiler.compile(validSettings());
assertThat(result).containsOnlyKeys(new FileDestinationId("local-export"));
assertThat(result.get(new FileDestinationId("local-export")).providerId())
.isEqualTo("local-primary");
}
```
Also reject blank IDs, unknown `provider-ref`, duplicate normalized IDs, non-absolute root, enabled
`auto-create`, unsupported publication/durability values, and non-positive row/byte bounds.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*FileserverBindingCompilerTest' --console=plain
```
Expected: compilation failure because the settings/compiler do not exist.
- [x] **Step 3: Implement typed settings**
Use one public configuration-properties record:
```java
@ConfigurationProperties(prefix = "app.fileserver")
public record FileserverR2Settings(
boolean enabled,
Map<String, DestinationSettings> destinations,
Map<String, ProviderSettings> providers) {
public record DestinationSettings(
String providerRef,
String requiredPublication,
String requiredDurability,
long maximumRows,
long maximumEncodedBytes) {}
public record ProviderSettings(
String type,
String rootDirectory,
boolean autoCreate,
boolean strictPathSecurity,
String expectedFileStoreName,
String expectedFileStoreType,
String mountSentinelName,
String mountSentinelSha256,
String expectedOwner,
String maximumRootMode) {}
}
```
The compiler accepts exactly:
```text
type=local-persistent
required-publication=unique-atomic-create
required-durability=file-and-directory-sync
auto-create=false
strict-path-security=true
```
`CompiledFileDestination` contains validated application destination ID, provider ID, absolute
root, limits, root attestation inputs, and no Spring type.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
---
### Task 3: Attest a pre-provisioned persistent root
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootEvidence.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestorTest.java`
- [x] **Step 1: Write failing attestation tests**
Create a real POSIX temporary root and sentinel. Test successful evidence and each fail-closed
condition:
```java
@Test
void attestsOwnerModeStoreSentinelSecureDirectoryAndSyncPrimitives() {
CompiledFileDestination destination = destinationFor(attestedRoot());
LocalPersistentRootEvidence evidence =
new LocalPersistentRootAttestor().attest(destination);
assertThat(evidence.root()).isEqualTo(root.toRealPath());
assertThat(evidence.secureDirectoryStream()).isTrue();
assertThat(evidence.directorySync()).isTrue();
assertThat(evidence.exclusiveHardLink()).isTrue();
}
```
Separate tests reject:
- relative or missing root;
- symlink root/ancestor;
- owner mismatch;
- group/world-writable root;
- FileStore name/type mismatch;
- missing, symlinked, non-regular, or digest-mismatched sentinel;
- staging/data/control on a different FileStore;
- unavailable `SecureDirectoryStream`, hard-link, or directory-force probe.
Probe collaborators may be package-private injectable functions so negative paths do not depend on
the host filesystem lacking a feature.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentRootAttestorTest' --console=plain
```
Expected: compilation failure because attestation types do not exist.
- [x] **Step 3: Implement strict attestation**
The attestor must:
```text
reject before creating anything when root/sentinel/owner/mode/store mismatch
capture root real path, file key, FileStore name/type, sentinel digest
create private .ca-fileserver, data, staging, operations, manifests, references, probe directories
set newly-created directories to 0700
force each created parent directory
open a SecureDirectoryStream on root
run unique exclusive-create + force + hard-link + directory-force probe
delete probe artifacts and force the probe directory
return immutable evidence used for pre/post identity checks
```
Do not silently downgrade to R1.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS on the supported Linux/POSIX lane.
---
### Task 4: Add strict reference, journal-v2, manifest, and reference records
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/R2PublishedReferenceCodec.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodecTest.java`
- [x] **Step 1: Write failing codec tests**
Test:
```java
@Test
void referenceRoundTripRejectsForgeryUnknownRouteAndTruncation() {
PublishedFileReference reference = codec.encode("routea1", fixedFileId());
assertThat(codec.decode(reference, Set.of("routea1")).fileId()).isEqualTo(fixedFileId());
assertThatThrownBy(() -> codec.decode(tamper(reference), Set.of("routea1")))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> codec.decode(reference, Set.of("routeb2")))
.isInstanceOf(IllegalArgumentException.class);
}
```
For all three records prove:
- canonical encode/decode round trip;
- maximum encoded length;
- exact schema version;
- state and revision invariants;
- single-segment internal locators;
- lowercase SHA-256 fields;
- no absolute path, raw row/cell, credential, URI, or control character;
- newer schema and duplicate/unknown fields fail closed.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*FileserverControlRecordCodecTest' --console=plain
```
Expected: compilation failure because R2 records/codecs do not exist.
- [x] **Step 3: Implement bounded canonical records**
Use a strict flat canonical JSON codec owned by this leaf. The record state is:
```java
enum State {
WRITING,
SEALED,
DATA_PUBLISHED,
MANIFEST_PUBLISHED,
REFERENCE_PUBLISHED,
PUBLISHED,
QUARANTINED
}
```
`R2PublishedReferenceCodec` uses:
```text
fsr1.<route-token>.<32-lower-hex-file-id>.<first-12-hex-of-sha256(prefix)>
```
The check digits detect corruption only and are not authentication.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
---
### Task 5: Persist forced control records and operation locks
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlaneTest.java`
- [x] **Step 1: Write failing control-plane tests**
Test direct lookup and forced revision handling:
```java
@Test
void storesAndDirectlyLoadsOperationManifestAndReferenceRecords() {
controlPlane.storeOperation(writingRecord());
controlPlane.storeManifest(manifest());
controlPlane.storeReference(referenceRecord());
assertThat(controlPlane.findOperation(OPERATION_ID)).contains(writingRecord());
assertThat(controlPlane.findManifest(FILE_ID)).contains(manifest());
assertThat(controlPlane.findReference(FILE_ID)).contains(referenceRecord());
}
```
Also prove:
- lower/equal incompatible state revision is rejected;
- request fingerprint mismatch is conflict;
- temp file is force-written before atomic replace;
- target parent is forced after replace;
- shard creation forces its parent;
- symlink shard/record is rejected with `NOFOLLOW_LINKS`;
- reads, temporary creation, stat, and delete use attested directory-relative names through
`SecureDirectoryStream`; operations without a portable secure hard-link/flagged atomic-replace
overload remain limited to the private-owner root and require pre/post identity checks;
- same operation is serialized by JVM stripe plus OS `FileLock`;
- record corruption is never treated as absent.
Use a package-private fault-point callback to observe/throw at:
```text
TEMP_FORCED
RECORD_REPLACED
PARENT_FORCED
```
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentControlPlaneTest' --console=plain
```
Expected: compilation failure because the control plane does not exist.
- [x] **Step 3: Implement durable storage**
All writes follow:
```text
CREATE_NEW sibling temp
write all bytes
FileChannel.force(true)
ATOMIC_MOVE + REPLACE_EXISTING for the control record only
force parent directory
read-back and verify identity/revision/digest
```
Payload publication must never use overwrite-capable move. Control record replacement is safe only
under the operation lock and monotonically increasing `stateRevision`.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
---
### Task 6: Implement the local-persistent R2 provider and deterministic recovery
**Files:**
- Modify:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java`
- Modify:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java`
- Modify:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java`
- Modify:
`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/FilePublicationProvider.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompilerTest.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlaneTest.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProviderTest.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationRecoveryTest.java`
- [x] **Step 1: Write failing publication-order tests**
First add failing compiler/control-plane assertions for:
```text
deterministic route token = "r" + first 31 lowercase hex of canonical policy digest
same startup allowlist route-token collision -> startup failure
length-prefixed effective policy/schema/format digest stability
same secure operation lookup -> typed canonical v1 or v2
v1 is read-only; malformed UTF-8/non-canonical/newer schema is indeterminate, never absent
control fault context identifies record kind, identity,
applicable operation state/revision, and force boundary
```
Then use a deterministic file ID/clock and a fault recorder. Prove exact order:
```text
J_WRITING
STAGE_FORCED
J_SEALED
DATA_LINKED
DATA_DIRECTORY_FORCED
J_DATA_PUBLISHED
MANIFEST_FORCED
J_MANIFEST_PUBLISHED
REFERENCE_FORCED
J_REFERENCE_PUBLISHED
J_PUBLISHED
```
Verify the receipt has an opaque `fsr1` reference,
`UNIQUE_ATOMIC_CREATE`, and `FILE_AND_DIRECTORY_SYNC`.
Also test producer once, streaming bounds, formula mitigation, target collision no overwrite,
root-identity change indeterminate, and manifest/reference locator non-disclosure. The stored
`internalLocator` is the generated filename only; its data shard is derived from the first two
hex characters of `fileId`.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*FileserverBindingCompilerTest' \
--tests '*LocalPersistentControlPlaneTest' \
--tests '*LocalPersistentPublicationProviderTest' --console=plain
```
Expected: compilation/test failure because the compiled identity, typed compatibility lookup,
contextual fault seam, payload operations, and provider do not exist.
- [x] **Step 3: Implement prerequisites and minimal R2 publication**
Compile one restart-stable destination identity without adding a config key:
```text
effectivePolicyDigest = SHA-256(length-prefixed canonical descriptor fields)
routeToken = "r" + first 31 lowercase hex of effectivePolicyDigest
```
The canonical descriptor includes destination/provider IDs, limits, required guarantees, and the
format/encoder revision. The schema and format policy use the same length-prefixed digest helper.
Reject route-token collisions across the compiled startup allowlist. Keep digest/token derivation
on the production SHA-256 path only. Exercise the otherwise impractical collision branch through
the same package-private pure route-registry check used by production, using two different test
digests whose first 31 hex characters collide; expose no digest/token runtime override.
Extend `LocalPersistentControlPlane` with one secure relative typed operation lookup. It returns
schema-v1 only through strict UTF-8 plus canonical v1 re-encode byte equality and never writes v1;
schema-v2 remains the only write format. Enrich its package-private fault callback with record kind,
identity, operation state/revision, and force boundary so Task 8 can stop at an exact record force.
The provider:
```text
validates destination and request before producer invocation
acquires operation lock
loads operation by direct ID
allocates fileId/name before WRITING
streams with existing StreamingCsvEncoder
forces stage and stores SEALED
exclusive hard-links data and forces data directory
publishes private manifest
publishes reference index
stores terminal receipt snapshot
returns only after terminal journal parent force/read-back
```
`LocalPersistentPayloadOperations` owns restrictive staging/data shard creation, secure relative
stage create/write/force, stable no-follow artifact inspection/digest, exact stage deletion,
exclusive no-replace hard-link, standalone recovery-time data-shard directory force, and
attested-root-relative R1 artifact inspection. Absolute hard-link/directory-force calls are allowed
only inside the attested private-owner boundary with file/root/directory identity checks. An
existing matching data artifact discovered from `SEALED` must have its shard directory forced
again before the journal may advance; it is never republished through a collision path. A
root-level R1 artifact is restored only after bounded SDS-relative no-follow inspection matches the
terminal R1 journal.
Before and after the hard-link commit, compare root real path, file key, FileStore, and sentinel
digest to `LocalPersistentRootEvidence`.
- [x] **Step 4: Write failing recovery matrix tests**
For every non-terminal state construct matching/missing artifacts and retry with a producer that
throws if called. Expected:
```text
SEALED + stage -> resume data publish
SEALED + matching data -> resume manifest
DATA_PUBLISHED -> resume manifest
MANIFEST_PUBLISHED -> resume reference
REFERENCE_PUBLISHED -> finish terminal journal
PUBLISHED + all matching -> restore exact receipt
non-terminal data/manifest/reference mismatch -> QUARANTINED / integrity failure
PUBLISHED artifact/metadata/receipt mismatch -> preserve all terminal evidence; integrity / indeterminate
required artifact missing -> fail-closed indeterminate / quarantine, never success
fingerprint mismatch -> CONFLICT
root identity mismatch -> PUBLISH_INDETERMINATE
WRITING producer/stage failure -> exact cleanup + unsealed QUARANTINED
retry with existing WRITING -> producer is not invoked; indeterminate / quarantine
retry of unsealed QUARANTINED -> producer is not invoked
```
`LocalPersistentRecoveryVerifier` must cross-check the operation, incoming request, stable data
digest, canonical manifest/reference digests, all locators/counts/timestamps, and guarantees.
Because operation schema v2 does not carry a standalone format-policy snapshot, it must require an
exact current compiled effective-policy revision/digest match before using the current
format-policy digest; it must fail closed instead of guessing across an encoder-policy change.
Current configured byte/row limits apply to a new attempt. Recovery inspection is bounded by the
already frozen operation byte size (with overflow-safe equality), so a later lower configuration
limit does not reinterpret a sealed artifact. If both stage and data exist, their stable file keys
must match before exact stage deletion; equal bytes alone are insufficient.
Restore a terminal receipt only when it equals the full receipt reconstructed from the verified
manifest/reference; checking only operation ID/count/SHA is insufficient. Reuse a verified
immutable manifest/reference `publishedAt` after a crash instead of generating a conflicting time.
`QUARANTINED` journal transitions are limited to non-terminal operations. A mismatch discovered
from `PUBLISHED` must not replace the terminal journal or delete/overwrite data, manifest, or
reference records; return typed integrity/indeterminate and preserve all terminal evidence. A
separate immutable quarantine incident record is outside this increment.
- [x] **Step 5: Write failing R1 compatibility tests**
Pre-provision an existing R1 root so it passes every R2 root attestation condition, then configure
that same root as the R2 destination. Place a valid journal schema-v1 terminal record at the shared
hashed operation path and a matching root-level R1 artifact.
The R2 reader may restore its original `PROCESS_LOCAL_SYNC` receipt, but must not create an R2
manifest/reference, change its guarantee, or rewrite the record as schema v2. Newer/corrupt R1
records remain indeterminate. Also prove malformed UTF-8 and a decodable but non-canonical v1
encoding fail, and that simultaneous R1/R2 bean activation is not required for migration.
- [x] **Step 6: Verify compatibility RED, then implement read-only compatibility**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentPublicationRecoveryTest' --console=plain
```
Expected before implementation: the R1 restoration assertion fails. Reuse the existing schema-v1
model/codec behind an added strict UTF-8 and canonical re-encode equality guard, only as a read-only
compatibility reader; do not add schema-v1 write paths or an unconfigured second root.
- [x] **Step 7: Verify recovery RED, then implement recovery**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentPublicationRecoveryTest' --console=plain
```
Expected before recovery implementation: failures at each resume assertion. Implement only the
matrix and verifier rules above. When producer or staging fails after `J_WRITING`, preserve the
original exception, attach cleanup/control failures as suppressed, exact-delete the partial stage,
and store unsealed `QUARANTINED` evidence so retry cannot replay the producer. A retry that finds
`WRITING` after a process crash also must not invoke the producer. Then rerun. Expected: PASS.
- [x] **Step 8: Verify provider GREEN**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentPublicationProviderTest' \
--tests '*LocalPersistentPublicationRecoveryTest' --console=plain
```
Expected: PASS.
---
### Task 7: Add one routing port bean and reject ambiguous R1/R2 activation
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java`
- Create:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java`
- Test:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2ConfigTest.java`
- Modify:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java`
- Rename:
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java`
to
`src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java`
- Modify:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java`
- Modify:
`src/app-bootstrap/build.gradle`
- Modify:
`src/config/architecture/modules.json`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/OptionalAdapterBeanGatingTest.java`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java`
- [x] **Step 1: Write failing composition/routing tests**
Prove:
```java
@Test
void disabledR2CreatesNoPortOrFilesystemSideEffect() {}
@Test
void enabledR2CreatesExactlyOneRoutingPortForExplicitBindings() {}
@Test
void requestForUnknownDestinationFailsBeforeProducerInvocation() {}
@Test
void enablingLegacyR1AndR2TogetherFailsStartup() {}
@Test
void configuredButUnimplementedSharedOrSftpProviderFailsStartup() {}
```
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*FileserverR2ConfigTest' --console=plain
```
Expected: compilation/test failure because R2 composition does not exist.
Execution note: the production composition skeleton had already been introduced before the
delegated test task returned, so a standalone RED Gradle run was no longer reproducible without
reverting work. The tests still exposed the missing method-level conditional gate through the
bootstrap architecture check; that failure was observed and fixed before GREEN.
- [x] **Step 3: Implement exact routing composition**
`RoutingFilePublicationAdapter` contains an immutable
`Map<FileDestinationId, FilePublicationProvider>` and delegates only after exact lookup.
`FileserverR2Config`:
- is conditional on `app.fileserver.enabled=true`;
- enables `FileserverR2Settings`;
- compiles and attests every configured binding at startup;
- creates one provider instance per provider ID;
- creates exactly one `FilePublicationPort`;
- rejects `ca-skeleton.fileserver.enabled=true` in the same environment before either R1 root
creation or R2 attestation, independently of Spring bean creation order;
- rejects different provider IDs that resolve to the same normalized root;
- never creates directories/connections when disabled.
The same package-private activation validator runs first in both R1 bean factories and the R2
routing factory; conditional precedence is not an acceptable substitute for an ambiguity failure.
Use strict configuration-properties binding (`ignoreUnknownFields = false`). Wire the fileserver
leaf into `app-bootstrap` through the architecture registry and Gradle dependency in this task so
the runtime composition is real, while keeping all local provider/control types private to the
leaf. Rename the legacy configuration-properties type to the repository-required `*Settings`
suffix before exposing this leaf to bootstrap naming checks.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
---
### Task 8: Add process-crash qualification, docs, and full gates
**Files:**
- Create:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java`
- Create:
`src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java`
- 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`
- Modify:
`docs/superpowers/specs/2026-07-28-fileserver-r2-control-plane-provider-selection-design.md`
- Modify:
`docs/superpowers/plans/2026-07-28-fileserver-r2-control-plane-provider-selection.md`
- Modify: `docs/registries/env-keys.yaml`
- [x] **Step 1: Write the failing forked-process crash test**
Launch a new JVM with the test runtime classpath. The helper receives a fault point and calls
`Runtime.getRuntime().halt(91)` immediately after that point. Cover:
```text
J_WRITING
STAGE_FORCED
J_SEALED
DATA_LINKED
DATA_DIRECTORY_FORCED
MANIFEST_FORCED
MANIFEST_DIRECTORY_FORCED
REFERENCE_FORCED
REFERENCE_DIRECTORY_FORCED
TERMINAL_JOURNAL_FORCED
TERMINAL_JOURNAL_DIRECTORY_FORCED
```
Restart in a second JVM/process and assert exact receipt restoration or a documented typed
indeterminate/quarantine outcome, never producer replay or partial final bytes.
Also run a forked cross-process operation-lock proof using the same attested root and operation ID:
process A acquires and reports the OS lock, process B uses a bounded non-blocking/timed attempt and
must not enter the critical section while A is alive, then must acquire after A releases or is
forcibly terminated. This proof must exercise the OS `FileLock`; the same-JVM stripe test is not a
substitute and every wait requires a timeout.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:fileserver:test \
--tests '*LocalPersistentCrashRecoveryTest' --console=plain
```
Expected: failure until every fault point is injectable and recoverable.
Execution note: the contextual control-plane and payload fault seams introduced in Task 6 already
covered all eleven boundaries. The first complete forked-process run therefore passed without a
new production hook; no implementation was reverted merely to manufacture a RED result.
- [x] **Step 3: Implement only missing fault hooks/recovery transitions**
Fault hooks remain package-private test collaborators. No runtime setting or production bean may
allow arbitrary process termination.
- [x] **Step 4: Verify focused and module checks**
Run:
```bash
cd src
./gradlew :application-core:check :adapter:outbound:fileserver:check --console=plain
```
Expected: PASS.
- [x] **Step 5: Update readiness documentation**
Record:
- provider-neutral control plane and exact selector implemented;
- `local-persistent` is the only qualified R2 provider;
- `FILE_AND_DIRECTORY_SYNC` does not claim physical device power-loss protection;
- `shared-mounted`, SFTP, reaper/retention/quota/observability remain unimplemented;
- R1 compatibility artifacts are never auto-promoted.
Register the exact local provider environment keys from the design (`ROOT`, expected FileStore
name/type, sentinel digest, expected owner) with restart-only policy and conditional
`app.fileserver.enabled` validation. Do not add SFTP/NFS keys before those providers exist.
- [x] **Step 6: Run full repository gates**
Run:
```bash
cd src
./gradlew check --console=plain
./gradlew \
:application-core:verifyDependencyLocks \
:adapter:outbound:fileserver:verifyDependencyLocks \
:app-bootstrap:verifyDependencyLocks \
:sample-portfolio:verifyDependencyLocks \
verifyCleanArchitectureDependencies \
verifyPublicPathSnapshot \
verifyEnvKeys --console=plain
git diff --check
```
Expected: all commands PASS.
- [x] **Step 7: Request final independent review**
Review against:
- the R2 design spec;
- HARD-STOP rules;
- provider fallback/activation ambiguity;
- path/symlink/mount identity;
- crash ordering and recovery;
- receipt guarantee truthfulness;
- R1 compatibility and no unrelated adapter dependency.
Fix every Critical/Important issue and rerun the affected focused test plus full gates.
@@ -0,0 +1,120 @@
# HTTP Client Canonical Zero-Binding Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` or `superpowers:executing-plans`. Repository policy
> overrides the skill's commit steps: do not stage, commit, amend, or push.
**Goal:** Make HTTP client activation an explicit canonical composition decision and prove that the
default zero-binding state creates no client, executor, shutdown guard, retry/circuit-breaker
registry, or transport resource.
**Architecture:** `adapter:outbound:httpclient` owns strict canonical configuration, immutable
binding/provider/catalog/readiness registries, and a pure activation resolver. `app-bootstrap` owns
the composition root that binds canonical properties and publishes an inert capability descriptor.
The existing JDK `OutboundHttpClient` remains an explicitly constructed R1 migration facade; its
legacy settings and infrastructure configuration must no longer be discovered automatically.
**Scope boundary:** This increment does not add Apache HC5, a provider factory, a real semantic
upstream binding, hard wire cancellation, TLS/DNS/proxy/auth, or an R2 readiness claim. Every current
ACTIVE selection must fail closed because the only derived readiness card remains
`NOT_IMPLEMENTED`.
---
### Task 1: Add strict canonical selection and provider binding models
**Files:**
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientExpectedState.java`
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfiguration.java`
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinder.java`
- Test:
`src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinderTest.java`
- [x] Write RED tests for the canonical YAML shape under
`ca-skeleton.capabilities.http-client` and `ca-skeleton.providers.http-client`.
- [x] Reject unknown fields, malformed IDs, unknown expected state, and any legacy input entering
canonical composition, including the DISABLED state.
- [x] Preserve `OutboundHttpSettings` constructors as migration API, but remove its global
`@ConfigurationPropertiesScan` participation.
- [x] Keep provider definitions inert data; configuration alone must not create a transport.
### Task 2: Add catalog/readiness registries and pure fail-closed activation resolution
**Files:**
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpOperationCatalogRegistry.java`
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientReadinessCardRegistry.java`
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/ResolvedHttpClientCapability.java`
- Create:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolver.java`
- Test:
`src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolverTest.java`
- [x] Prove `DISABLED + bindings 0 + provider definitions 0` resolves to
`DISABLED_VERIFIED`, selected binding/card count 0.
- [x] Reject `DISABLED` with bindings or provider resources.
- [x] Reject `ACTIVE` with zero bindings.
- [x] For every binding, require an exact provider, provider destination, and registered operation
catalog for the same destination.
- [x] Derive the `httpclient-static-buffered` card from each current buffered classic profile.
- [x] Mark that card `NOT_IMPLEMENTED`; reject ACTIVE before any provider resource/factory exists.
### Task 3: Move HTTP Spring activation to the composition root
**Files:**
- Modify:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientConfig.java`
- Modify:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java`
- Modify:
`src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfig.java`
- Create:
`src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfig.java`
- Modify: `src/app-bootstrap/src/main/resources/application.yml`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java`
- Test:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfigTest.java`
- [x] Detach legacy HTTP infrastructure from component/configuration-properties scanning while
preserving direct constructors/factory methods used by forks and existing unit tests.
- [x] Register only canonical configuration, immutable registries, resolver, and inert descriptor
in the composition root.
- [x] Default application YAML to canonical `expected-state: DISABLED`, empty bindings, and empty
provider definitions; keep legacy migration keys out of both main and test application YAML.
- [x] Assert zero `OutboundHttpClient`, `RestClient`, `OutboundCallExecutor`,
`OutboundHttpShutdownGuard`, `OutboundHttpResilience`, `RetryRegistry`, and
`CircuitBreakerRegistry` beans/resources in the default context.
- [x] Assert contradictory/ACTIVE configurations fail startup before resource construction.
- [x] Load the real `application.yml` in composition tests and prove ACTIVE reaches the
`NOT_IMPLEMENTED` readiness card rather than a legacy conflict.
### Task 4: Document 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`
- Modify:
`docs/superpowers/plans/2026-07-28-httpclient-production-capability-foundation.md`
- [x] Mark canonical zero-binding as implemented without marking HTTP R2 complete.
- [x] Keep HC5/provider resources/security/real-network qualification explicitly unimplemented.
- [x] Run focused tests:
```bash
cd src
./gradlew :adapter:outbound:httpclient:check --rerun-tasks --console=plain
./gradlew :app-bootstrap:check --rerun-tasks --console=plain
./gradlew :sample-portfolio:test --rerun-tasks --console=plain
./gradlew verifyCleanArchitectureDependencies verifyConfigurationPropertiesProcessor \
verifyEnvKeys verifyPublicPathSnapshot --console=plain
```
Do not edit unrelated notification, messaging, object-storage, JPA, MongoDB, GraphQL, gRPC, web, or
WebSocket files.
@@ -12,9 +12,10 @@ own feature-specific semantic ports. `adapter:outbound:httpclient` owns destinat
immutable operation descriptors, relative target construction, status/retry/body semantics, and immutable operation descriptors, relative target construction, status/retry/body semantics, and
legacy provider fixes. The generic `OutboundHttpClient` remains a migration facade. legacy provider fixes. The generic `OutboundHttpClient` remains a migration facade.
**Scope boundary:** This applies Phase 0 and a bounded Phase 1 foundation. Canonical binding **Scope boundary:** This applies Phase 0 and a bounded Phase 1 foundation. Canonical zero-binding
composition, exact readiness tuple registry, Apache HC5 pool, active cancellation, TLS/DNS/proxy, composition and active logical cancellation were implemented by later tracked plans. Exact
auth, codec, and real-network qualification remain unimplemented. readiness tuple registry, Apache HC5 pool, TLS/DNS/proxy, auth, codec, and real-network
qualification remain unimplemented.
--- ---
@@ -24,9 +25,9 @@ auth, codec, and real-network qualification remain unimplemented.
- Create: `src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java` - 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` - 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. - [x] Write RED tests for expiry, remaining time, finite bounds, and parent/child intersection.
- [ ] Implement without Spring, wall-clock timestamps, scheduler, or HTTP types. - [x] Implement without Spring, wall-clock timestamps, scheduler, or HTTP types.
- [ ] Verify GREEN. - [x] Verify GREEN.
### Task 2: Add typed operation catalog and safe target construction ### Task 2: Add typed operation catalog and safe target construction
@@ -41,11 +42,11 @@ auth, codec, and real-network qualification remain unimplemented.
- 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/operation/HttpOperationCatalogTest.java`
- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/target/HttpTargetBuilderTest.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. - [x] Write RED tests for ID/uniqueness/cross-field operation invariants.
- [ ] Write RED tests rejecting absolute, scheme-relative, traversal, user-info, query/fragment, and - [x] Write RED tests rejecting absolute, scheme-relative, traversal, user-info, query/fragment, and
multi-segment variables. multi-segment variables.
- [ ] Implement closed immutable descriptors and one-pass path-segment encoding. - [x] Implement closed immutable descriptors and one-pass path-segment encoding.
- [ ] Verify GREEN. - [x] Verify GREEN.
### Task 3: Correct characterized legacy provider safety defects ### Task 3: Correct characterized legacy provider safety defects
@@ -54,11 +55,11 @@ auth, codec, and real-network qualification remain unimplemented.
- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.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` - 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. - [x] Reproduce streaming 5xx body delivery and logical-call-only circuit-breaker counting.
- [ ] Make streaming validate status before exposing the body and discard error bodies. - [x] 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. - [x] 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. - [x] Set JDK redirects to `NEVER` explicitly and validate legacy base URI/relative request targets.
- [ ] Verify focused regressions and the full legacy test suite. - [x] Verify focused regressions and the full legacy test suite.
### Task 4: Record exact readiness and verify ### Task 4: Record exact readiness and verify
@@ -67,10 +68,10 @@ auth, codec, and real-network qualification remain unimplemented.
- Modify: `src/adapter/outbound/httpclient/CLAUDE.md` - Modify: `src/adapter/outbound/httpclient/CLAUDE.md`
- Modify: `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md` - Modify: `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md`
- [ ] Mark the implemented foundation and fixed legacy defects. - [x] Mark the implemented foundation and fixed legacy defects.
- [ ] Keep total deadline/cancellation, canonical zero-binding composition, Apache pool, fixed - [x] Track later total-deadline and canonical-zero-binding increments separately while keeping
egress, TLS/auth, bounded decoded streaming, and R2 cards unimplemented. Apache pool, fixed egress, TLS/auth, bounded decoded streaming, and R2 cards unimplemented.
- [ ] Run: - [x] Run:
```bash ```bash
cd src cd src
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
@@ -0,0 +1,52 @@
# Redis Cache Resilience Implementation Plan
> Repository commit policy is human-only. Do not stage, commit, amend or push.
**Goal:** Implement the approved cache-aside, bounded source protection and soft/hard TTL design
without promoting Redis beyond standalone cache R1.
### Task 1: Application cache-aside outcomes and policy
**Files:**
- Create/modify `src/application-core/src/main/java/dev/caskeleton/application/cache/*`
- Test `src/application-core/src/test/java/dev/caskeleton/application/cache/*`
- [x] Write RED tests for fresh/negative/miss/stale/source outcome transitions.
- [x] Add typed loader, failure, result, cancellation and immutable policy contracts.
- [x] Implement cache-aside sequencing; only authoritative absence may be negative-cached.
- [x] Preserve unclassified exceptions and interruption.
- [x] Verify focused application cache tests GREEN.
### Task 2: Bounded local single-flight and source bulkhead
**Files:**
- Create `CacheSingleFlight.java`
- Create `CacheSourceBulkhead.java`
- Test their concurrency behavior through focused unit tests.
- [x] Write RED concurrency tests.
- [x] Bound in-flight keys, waiters, admission wait and load wait.
- [x] Remove completed/failed/abandoned flights and preserve loader failure fan-out.
- [x] Prove Redis outage cannot create unlimited source concurrency.
### Task 3: Redis soft/hard TTL, jitter and stale envelope
**Files:**
- Modify `RedisCacheRegionPolicy.java`
- Modify `RedisCacheEnvelopeCodec.java`
- Modify `RedisStringCacheRegion.java`
- Modify/add focused Redis cache tests.
- [x] Write RED boundary, jitter, minimum and schema-compatibility tests.
- [x] Add an injected `Clock` and deterministic policy-revision jitter.
- [x] Encode absolute soft/hard expiry in envelope version 2.
- [x] Use the encoded hard expiry as physical Redis TTL.
- [x] Verify focused Redis tests GREEN.
### Task 4: Documentation and verification
- [x] Synchronize the completed foundation-plan checkboxes with existing code/evidence.
- [x] Update Redis README/CLAUDE/design readiness truth.
- [ ] Run application and Redis leaf checks.
- [ ] Run dependency locks, architecture, public path, env and diff checks.
- [x] Request independent specification and code-quality review.
@@ -0,0 +1,45 @@
# Redis Distributed Rate-Limit Implementation Plan
> Repository commit policy is human-only. Do not stage, commit, amend or push.
### Task 1: Shared edge rate-limit contract
- [x] Write RED contract/policy tests in `shared-contract`.
- [x] Add bounded request, algorithm parameters, policy, decision, outcome and port types.
- [x] Reject unsupported dedup/failure claims and unsafe fixed-point arithmetic.
- [x] Verify the shared contract without Redis/Spring types.
### Task 2: Structured Redis program execution
- [x] Write RED tests for MULTI reply arity/status/ASCII integer bounds and `NOSCRIPT`.
- [x] Add bounded structured `EVALSHA`/`EVAL` command support without changing scalar primitives.
- [x] Add exact catalog descriptors and resource digests for three rate programs.
### Task 3: Three atomic algorithms and semantic provider
- [x] Implement fixed-window Lua and golden vectors.
- [x] Implement sliding-counter Lua with conservative fixed-point arithmetic.
- [x] Implement token-bucket Lua with saturation and exact ceiling retry.
- [x] Add canonical private keys, policy lookup and typed failure mapping.
- [x] Prove denial does not consume quota and revision changes physical state.
### Task 4: Dedicated runtime and explicit composition
- [x] Add strict `app.rate-limit` settings and disabled-zero-side-effect configuration.
- [x] Use a dedicated coordination runtime rather than cache Redis beans/settings.
- [x] Add exact environment registry/application configuration entries.
- [x] Keep readiness at standalone provider R1.
### Task 5: Verification and review
- [x] Run shared/Redis/bootstrap focused checks.
- [x] Run architecture/dependency/env/diff gates.
- [ ] Run the public-path gate with the final combined change set.
- [x] Run an explicit real Redis lane when a service is available.
- [x] Request independent spec and quality review.
The Redis 7.4 service lane executes the exact-boundary admission after a denied non-consuming
request for all three algorithms, excessive clock-regression state immutability, token refill
remainder carry, malformed hash classification, cache NX, and observation-token compare-replace.
The program manifests therefore declare 7.4 as the minimum qualified version until a lower-version
service lane exists.
@@ -31,10 +31,10 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p
- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/AuthoritativeAbsence.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` - 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. - [x] Write a failing test for hit/negative/miss/unavailable distinctions and immutable metadata.
- [ ] Verify RED with `./gradlew :application-core:test --tests '*CacheRegionContractTest'`. - [x] Verify RED with `./gradlew :application-core:test --tests '*CacheRegionContractTest'`.
- [ ] Implement only framework-free values and ports. - [x] Implement only framework-free values and ports.
- [ ] Verify GREEN. - [x] Verify GREEN.
### Task 2: Add canonical Redis physical keys ### Task 2: Add canonical Redis physical keys
@@ -45,12 +45,12 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyDigest.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` - 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 - [x] Write a failing test proving namespace isolation, one stable hash tag, bounded key bytes, and
absence of raw sensitive resource identifiers. absence of raw sensitive resource identifiers.
- [ ] Verify RED. - [x] Verify RED.
- [ ] Implement SHA-256 for opaque IDs and HMAC-SHA-256 for sensitive scopes using defensive secret - [x] Implement SHA-256 for opaque IDs and HMAC-SHA-256 for sensitive scopes using defensive secret
copies and length-prefixed component encoding. copies and length-prefixed component encoding.
- [ ] Verify GREEN. - [x] Verify GREEN.
### Task 3: Add a typed, versioned atomic-program catalog ### Task 3: Add a typed, versioned atomic-program catalog
@@ -66,11 +66,11 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p
- 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/RedisProgramCatalogTest.java`
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisAtomicPrimitivesTest.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. - [x] Write failing catalog and facade tests.
- [ ] Verify RED. - [x] Verify RED.
- [ ] Implement exact resource digest, key/argument bounds, typed status mapping, and no generic - [x] Implement exact resource digest, key/argument bounds, typed status mapping, and no generic
application-facing execution surface. application-facing execution surface.
- [ ] Verify GREEN. - [x] Verify GREEN.
### Task 4: Record exact readiness and verify ### Task 4: Record exact readiness and verify
@@ -79,9 +79,9 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p
- Modify: `src/adapter/outbound/cache-redis/CLAUDE.md` - Modify: `src/adapter/outbound/cache-redis/CLAUDE.md`
- Modify: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.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 - [x] Mark only contract/key/program foundation as implemented and all real runtime/capability
promotion as unimplemented. promotion as unimplemented.
- [ ] Run: - [x] Run:
```bash ```bash
cd src cd src
@@ -89,5 +89,5 @@ cd src
./gradlew verifyCleanArchitectureDependencies --console=plain ./gradlew verifyCleanArchitectureDependencies --console=plain
``` ```
- [ ] Do not claim Redis cache R1/R2 until a real standalone service lane and codec/runtime evidence - [x] Do not claim Redis cache R1/R2 until a real standalone service lane and codec/runtime evidence
exist. exist.
@@ -0,0 +1,662 @@
# Redis Production Capability Completion Plan
> **Scope:** Redis를 먼저 완료한다. 현재 실행 단위는 deep design Phase 5 전체가 아니라
> `Sentinel-first R2 qualification slice`다. 이 slice의 검증과 보고가 끝나면 멈추고
> fileserver, HTTP client, Redis Cluster/R3 중 다음 우선순위를 다시 정한다.
>
> **Workflow note:** 저장소가 지정한 Superpowers 설계·계획·TDD·디버깅·검증·리뷰 워크플로우를
> 적용한다. agent는 human-only commit 정책에 따라 stage/commit/amend/push하지 않는다.
**Goal:** `2026-07-26-redis-production-capability-design.md`의 Phase 15를 capability별로 구현하고,
standalone 기능의 존재를 production readiness로 오표기하지 않는 Redis platform을 만든다.
**Architecture:** `application-core``shared-contract`는 provider-neutral semantic contract만
소유한다. `adapter:outbound:cache-redis`가 Redis deployment, topology, key, codec, program,
runtime과 capability provider를 소유한다. `adapter:inbound:web`은 HTTP rate/session 보안 매핑만,
`app-bootstrap`은 provider/role/auth-mode composition만 소유한다. `domain-core`에는 Redis 개념을
추가하지 않는다.
**Readiness rule:** Redis leaf 전체에 단일 R2 label을 부여하지 않는다. `redis-cache`,
`redis-edge-rate-limit`, `redis-request-replay-idempotency`,
`redis-cache-refresh-soft-lease`, `redis-fenced-coordination`, `redis-session` card가 독립적으로
승격한다. R3 증거가 없는 failover/reshard/rotation은 R2 범위로 과장하지 않는다.
**Worktree rule:** 현재 `main` worktree의 다른 기술 변경은 사용자 소유다. Redis가 소유하지 않는
fileserver, HTTP client, messaging, notification, object storage 변경을 되돌리거나 포맷하지 않는다.
**Current milestone exit:** agent-side 목표는 `R2-ready candidate`다. clean committed source와
실제 remote GitHub Actions evidence가 없으면 card를 `selected`로 바꾸거나 R2라고 주장하지 않는다.
---
## Task 0 — Baseline과 acceptance registry 고정
**Files**
- Create: `src/config/redis/readiness-cards.yaml`
- Create: `src/gradle/redis-test-images.properties`
- Modify: `src/adapter/outbound/cache-redis/README.md`
- Modify: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`
**Tests first**
- registry가 canonical card ID 여섯 개를 정확히 한 번 포함하는지 실패 테스트를 작성한다.
- image tag에 exact version과 digest가 없으면 configuration이 실패하는 테스트를 작성한다.
- `selected`, `implemented-candidate`, `not-implemented` 이외 상태를 거절한다.
- 현재 구현과 다른 readiness 표기를 거절한다.
**Implementation**
- 시작 상태는 cache/rate를 `implemented-candidate`, 나머지는 `not-implemented`로 기록한다.
- 실제 required evidence가 생기기 전에는 어떤 card도 `selected` R2로 승격하지 않는다.
- Redis minimum version은 실행 가능한 image/digest와 program manifest를 한 SSOT로 맞춘다.
**Verification**
```bash
cd src
./gradlew :adapter:outbound:cache-redis:test --tests '*RedisReadinessRegistryTest' --console=plain
```
## Task 1 — Canonical deployment/topology/role model
**Files**
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderProperties.java`
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettings.java`
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactory.java`
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRole.java`
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRoleBinding.java`
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactoryTest.java`
- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderPropertiesBindingTest.java`
**Tests first**
- topology는 `standalone|sentinel|cluster` 중 정확히 하나다.
- endpoint는 non-empty, unique, bounded host/port다.
- Sentinel은 master name, 최소 3개 discovery endpoint, data/Sentinel auth와 TLS를 분리한다.
- Cluster는 database 0만 허용하고 seed가 비어 있으면 실패한다.
- role은 존재하는 deployment만 참조한다.
- cache와 session/coordination의 incompatible co-location을 startup 전에 거절한다.
- provider 정의만 있고 capability binding이 없으면 runtime side effect가 0이다.
**Implementation**
- Spring binding class와 validated sealed runtime model을 분리한다.
- legacy `app.cache.redis``app.rate-limit`은 migration compiler 입력으로만 허용하고 canonical
model과 동시에 설정되면 precedence를 정하지 않고 실패한다.
- `ClientMode.EXTERNAL`을 topology로 취급하지 않는다.
**Verification**
```bash
cd src
./gradlew :adapter:outbound:cache-redis:test --tests '*RedisDeploymentSettings*' --console=plain
```
## Task 2 — Topology-aware runtime, TLS/ACL과 secret material
**Files**
- Create: `.../redis/runtime/RedisDeploymentRuntime.java`
- Create: `.../redis/runtime/RedisDeploymentRuntimeFactory.java`
- Create: `.../redis/runtime/StandaloneRedisDeploymentRuntime.java`
- Create: `.../redis/runtime/SentinelRedisDeploymentRuntime.java`
- Create: `.../redis/runtime/ClusterRedisDeploymentRuntime.java`
- Create: `.../redis/security/RedisCredentialMaterialProvider.java`
- Create: `.../redis/security/RedisCredentialRotationCoordinator.java`
- Modify: `src/adapter/outbound/cache-redis/build.gradle`
- Modify: `src/adapter/outbound/cache-redis/gradle.lockfile`
**Tests first**
- standalone/Sentinel/Cluster가 각자 다른 native client/runtime을 만든다.
- Sentinel discovery credential/trust와 data-node credential/trust가 섞이지 않는다.
- Cluster client는 periodic+adaptive topology refresh, DB 0, bounded redirect/queue profile을 가진다.
- production profile에서 plaintext, trust-all, hostname verification off를 거절한다.
- named ACL username이 없거나 raw password가 YAML에 있으면 production activation이 실패한다.
- duplicate/out-of-order rotation event, expiry 재조회, new connection 검증 실패가 old traffic을
안전하게 보존한다.
- disabled capability는 client/event-loop/subscriber/scheduler를 만들지 않는다.
**Implementation**
- direct `spring-data-redis`, `lettuce-core` dependency를 leaf가 소유한다.
- deployment별 client resources와 lifecycle을 소유한다.
- connect/TLS/acquire/command/overall/shutdown timeout을 분리한다.
- 기존 no-replay, disconnected reject, finite queue/count/byte admission을 topology runtime에도
보존한다.
- secret value/reference/provider exception을 log/metric에 남기지 않는다.
## Task 3 — Key, codec, program manifest foundation
**Files**
- Create: `src/config/redis/program-set.schema.json`
- Modify: `src/adapter/outbound/cache-redis/src/main/resources/redis/program-set.json`
- Modify: `src/adapter/outbound/cache-redis/src/main/resources/redis/rate-program-set.json`
- Modify: `.../redis/RedisProgramDescriptor.java`
- Modify: `.../redis/RedisProgramCatalog.java`
- Modify: `.../redis/RedisLuaProgramExecutor.java`
- Create: `.../redis/key/RedisKeyMaterialProvider.java`
- Create: `.../redis/codec/RedisCapabilityCodec.java`
**Tests first**
- 모든 program은 exact source digest, semantic version, ordered KEYS/ARGV, result schema, slot rule,
state/TTL bound, minimum Redis version, retry/certainty, ACL command를 가진다.
- manifest와 Java descriptor가 drift하면 build가 실패한다.
- `NOSCRIPT` recovery는 bounded `SCRIPT LOAD -> EVALSHA`이고 arbitrary source 실행 surface가 없다.
- same-resource multi-key는 real `CLUSTER KEYSLOT`과 같은 slot이다.
- key digest material rotation은 fixed/dual-read-delete/cold-cutover rule을 지킨다.
- cache/idempotency/session codec은 N/N-1, future/corrupt/oversize/forbidden type을 구분한다.
**Implementation**
- foundation/rate manifest를 하나의 versioned registry contract로 통합하되 capability package와
facade는 분리한다.
- raw command, raw key, generic program executor를 Spring/application public surface에 노출하지 않는다.
## Task 4 — Cache consistency spine와 semantic region composition
**Files**
- Modify: `src/application-core/src/main/java/dev/caskeleton/application/cache/*`
- Create: `.../redis/cache/RedisCacheGenerationStore.java`
- Create: `.../redis/cache/RedisCacheRegionCompiler.java`
- Add resources: `region-generation-init-v1.lua`, `region-generation-bump-v1.lua`,
`cache-record-if-generation-v1.lua`
- Modify: `.../redis/RedisStringCacheRegion.java`
- Tests: application barrier tests, Redis real-service concurrency tests, binding tests
**Tests first**
- source load 중 generation bump가 일어나면 old result가 visible하지 않다.
- captured generation과 source revision이 바뀌면 stale writer가 새 값을 덮어쓰지 않는다.
- generation init race에서 하나의 canonical generation만 선택된다.
- operation ID가 같은 bump replay는 한 번만 적용된다.
- 여러 semantic region의 duplicate/missing binding은 fail-fast다.
- 실제 consumer가 semantic `CacheRegionPort``CacheAsideExecutor`를 사용하고 legacy fail-open
router와 암묵적으로 섞이지 않는다.
**Implementation decision**
- source revision은 opaque하므로 lexical “newer” 비교를 하지 않는다.
- region generation은 mass invalidation fence다.
- per-key invalidation은 해당 key의 revision/tombstone fence를 사용해 region 전체를 bump하지 않는다.
- write는 captured generation/revision condition을 만족할 때만 기록한다.
## Task 5 — Distributed refresh soft lease, L1/L2와 cache observability
**Files**
- Create application cache refresh coordination contracts without Redis types.
- Create Redis refresh claim/release programs and semantic provider.
- Create bounded L1 cache decorator and invalidation subscriber/reconciler.
- Create framework-free cache observation events and Micrometer adapter instrumentation.
- Update `docs/registries/metrics.yaml`.
**Tests first**
- 두 pod simulation에서 정상 시 refresh owner는 하나다.
- lease expiry에서는 duplicate load를 허용하지만 generation guard가 stale write를 차단한다.
- disconnected invalidation subscriber는 L1을 flush하고 generation을 재확인한다.
- Pub/Sub event loss에도 L1 TTL/generation reconciliation으로 stale bound를 지킨다.
- L1 max weight/cardinality/TTL, subscriber queue, refresh scheduler가 모두 bounded다.
- Redis liveness는 애플리케이션 liveness를 내리지 않는다.
- optional cache outage는 `DEGRADED`, required coordination/session outage는 `NOT_READY`다.
- cache role eviction/OOM에서 source concurrency와 queue가 bounded다.
## Task 6 — Edge rate limit end-to-end
**Files**
- Modify: `src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/*`
- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/*`
- Modify: `src/adapter/outbound/cache-redis/src/main/java/.../redis/*rate*`
- Modify: `src/app-bootstrap` composition
**Tests first**
- inbound가 process-local map이 아니라 `EdgeRateLimitPort`를 호출한다.
- subject는 raw principal/IP가 아닌 bounded pseudonymous digest다.
- fixed/sliding-counter/token-bucket reference/property/concurrency vector를 통과한다.
- evaluation ID replay가 quota를 두 번 소비하지 않는다.
- bounded local emergency는 configured degraded provider일 때만 동작한다.
- Redis/local/disabled provider exclusivity, shadow/degraded source, 429/503와 `Retry-After` mapping을
검증한다.
- legacy unbounded map과 silent primary fallback을 제거한다.
## Task 7 — Idempotency v2와 Redis provider
**Files**
- Replace/extend `src/application-core/.../idempotency` with owner-safe v2 contracts.
- Add Redis idempotency state programs/provider/codec.
- Migrate the existing JPA provider to the same semantic contract only after checking its separate
worktree changes; never overwrite concurrent persistence work.
**Tests first**
- atomic claim, fingerprint mismatch, owner/attempt-safe start/renew/complete/fail/release/inspect.
- processing TTL과 replay TTL 분리.
- expired `CLAIMED` takeover, expired `EXECUTING -> RECOVERY_REQUIRED`.
- response-loss replay/reconciliation, conflicting response digest reject.
- unverified cross-store effect는 자동 discard/re-execution하지 않는다.
- JDBC/Redis provider가 같은 scope를 동시에 claim하지 않는다.
**Implementation**
- Redis가 cross-store exactly-once를 보장한다고 표현하지 않는다.
- JPA migration 충돌이 있으면 Redis completion의 명시적 integration blocker로 보고하고 해당
worktree의 결과와 재대조한다.
## Task 8 — Efficiency lease와 optional fenced coordination
**Tests first**
- acquire/inspect/renew/release가 owner+operation token을 비교한다.
- response loss는 `UNKNOWN/INDETERMINATE`이며 same token inspect로 reconcile한다.
- expired old owner는 renew/release할 수 없다.
- watchdog는 bounded scheduler와 cancellation을 사용하고 lost 상태를 전달한다.
- fenced card를 선택하면 durable epoch/high-watermark 등록과 protected-resource stale-token reject를
실제 fixture로 증명한다.
**Implementation**
- close-only `DistributedLock`은 compatibility facade로 유지하되 새 코드가 strong lock으로
오해하지 않게 guarantee를 명명한다.
- fencing 없는 Redis lease를 business correctness lock으로 광고하지 않는다.
## Task 9 — Redis Session과 JWT/session exclusive composition
**Files**
- Add direct `spring-session-core` and `spring-session-data-redis` to Redis leaf.
- Add adapter-internal versioned session store/programs/serializer.
- Add inbound web cookie/CSRF/fixation settings and security configuration.
- Add app-bootstrap `jwt|redis-session` exclusive composition.
**Tests first**
- JWT mode는 session Redis connection/bean/thread side effect가 0이다.
- pod A create/save, pod B read/touch/logout.
- idle/absolute expiry, rotation, old ID reject, stale save after logout reject.
- explicit allowlisted serializer N/N-1 and corrupt payload re-auth.
- secure/httpOnly/SameSite/host-only cookie, CSRF enabled, fixation rotation.
- repository outage/noeviction OOM/failover는 fail-open 인증으로 바뀌지 않는다.
- indexed repository는 별도 opt-in이며 Cluster event cleanup 한계를 독립 검증한다.
## Task 10 — Real-service, topology, fault와 readiness Gradle tasks
**Files**
- Create: `src/adapter/outbound/cache-redis/src/redisTest/**`
- Modify: `src/adapter/outbound/cache-redis/build.gradle`
- Modify: `src/build.gradle`
- Create/update Redis test topology resources and sanitized evidence reporter
**Public tasks**
- `redisStandaloneTest`, `redisSecurityTest`, `redisSentinelTest`, `redisClusterTest`,
`redisFaultTest`, `redisCompatibilityTest`
- capability card test/readiness tasks named exactly as Redis deep design §37.22
- root `redisProductionReadiness`, `redisAllImplementedCandidates`
**Rules**
- selected evidence에서 Docker/service 부재나 0 discovered tests는 failure다.
- unselected card는 skipped가 아니라 `not selected`다.
- image/program/config digest와 sanitized JUnit/topology timeline을 evidence artifact로 남긴다.
## Task 11 — Container topology와 3-node k3s qualification
이번 실행은 deep design §37.13/Phase 5A의 Sentinel-first slice만 다룬다. Cluster, fenced
coordination, R3 long chaos/soak, k3s control-plane HA, physical host/AZ failure, full
credential/certificate rotation은 후속 작업이다.
### Task 11.1 — Lab lifecycle contract와 host isolation RED
이 작업은 리뷰 경계를 다음처럼 분리한다. 두 하위 작업이 모두 독립 리뷰를 통과하기 전에는 부모
Task 11.1을 완료로 표시하지 않는다.
- `Task 11.1A-1`: VM lifecycle, ownership marker/state, lock/signal/handoff cleanup, host
fingerprint와 bounded command. 현재 구현을 동결한다.
- `Task 11.1A-2`: pinned K3s generated-kubeconfig strict validator/renderer. 실행 계획은
`docs/superpowers/plans/2026-07-30-redis-lab-strict-kubeconfig-renderer.md`를 따른다.
2026-07-30 상태: `Task 11.1A-1` lifecycle/ownership과 `Task 11.1A-2` strict renderer는
whole-task 독립 review에서 Critical `0`, Important `0`, Minor `0`, SPEC PASS /
QUALITY APPROVED를 받았다. fresh direct/Gradle fake-only 검증도 통과해 부모 `Task 11.1A`
fake-only 범위는 완료다. 이는 live VM/k3s/kubectl/network/host qualification이나 Redis
R2 readiness 완료를 의미하지 않는다.
**Tracked files**
- Create: `infra/redis-lab/README.md`
- Create: `infra/redis-lab/versions.env`
- Create: `infra/redis-lab/bin/redis-lab`
- Create: `infra/redis-lab/cloud-init/node.yaml`
- Create: `infra/redis-lab/test/redis-lab-contract.sh`
- Modify: Redis Gradle VM-free lifecycle contract task
**Tests first**
- VM 이름은 `ca-redis-lab-server`, `ca-redis-lab-agent-1`,
`ca-redis-lab-agent-2` exact allowlist만 허용한다.
- server 1 + agent 2, resource `2/3GiB/12GiB`, `2/2.5GiB/12GiB`,
`2/2.5GiB/12GiB`, pod CIDR `10.52.0.0/16`, service CIDR
`10.53.0.0/16`, context `ca-redis-lab`을 검증한다.
- host 관측은 default kubeconfig의 run-scoped copy와 원래 host context를 사용하고 read-only
allowlist만 허용한다. lab 호출은 별도 ignored `src/build/redis-lab/kubeconfig`와 exact
`ca-redis-lab` context를 사용한다.
- default kubeconfig merge/write, host context mutation, wildcard VM cleanup, global
`multipass purge`를 정적/동적 contract가 거절한다.
- preflight/postflight host kubeconfig/context/node/workload fingerprint가 다르면 실패한다.
- CI는 retain-on-failure를 거절하고, local opt-in만 exact VM 보존을 허용한다.
- fake `multipass`/`kubectl`을 주입하는 shell contract는 partial-create cleanup과 exact command
allowlist를 VM 생성 없이 검증하고 `redisLabContractTest`로 module `check`에 연결한다.
- launch 전 exact name을 run-owned `PENDING`으로 atomic 예약하고 성공 직후 `CREATED`
승격한다. timeout/실패/상태 승격 실패는 이 run이 예약한 exact name만 정리한다.
- private run-scoped rendered cloud-init은 non-secret `RUN_ID|VM_NAME` ownership marker를
기록한다. cleanup/down은 bounded marker read가 state owner와 exact name 일치를 증명할
때만 delete한다. launch timeout/error는 `RECONCILE` tombstone과 bounded late-create poll로
처리하며 absent/unreadable/mismatch는 delete/state removal 없이 fail-closed한다.
- lifecycle 전체는 nonblocking exclusive lock과 run identity를 사용한다. direct `up`
`run` 모두 첫 launch 전 emergency cleanup을 활성화하고, signal/concurrent 실행이 다른
run state나 VM을 채택·삭제하지 못한다. user command에는 lock file descriptor를 상속하지
않으며 기본 bounded external child도 FD를 닫고 lock acquisition만 예외로 유지한다.
`run`의 inner `up` 성공과 user command 시작 사이에도 cleanup-required flag가 연속 유지돼
zero-ownership handoff gap이 없어야 한다.
- host kubeconfig copy는 fingerprint/CIDR 관측 범위가 끝나면 성공/실패와 무관하게 제거한다.
- lab kubeconfig renderer는 denylist/generic-count 보강을 사용하지 않는다. pinned K3s의
canonical block-style one-cluster/context/user grammar를 별도 tracked AWK state machine으로
allowlist하며, catch-all pass-through 없이 duplicate/extra/reordered/unknown/flow-style
identity와 모든 비허용 구조를 fail-closed로 거절한다.
- external command와 3-node Ready 대기는 bounded이고, host service CIDR은 assigned
ClusterIP에서 추측하지 않고 명시적 validated input 또는 신뢰 가능한 host 설정에서 얻는다.
- mutable `curl | sudo sh` installer는 금지한다. exact K3s release URL과 SHA-256을 repository에
pin하고 host download와 각 VM transfer 뒤 다시 검증한 후에만 install/start한다.
- shell contract는 별도 fixture repository에서 실행하고 actual `src/build/redis-lab` canary를
byte-for-byte 보존한다. fake PATH는 explicit safe wrapper 외 모든 명령을 fail-closed한다.
### Task 11.2A — Sentinel manifest와 security static contract GREEN
**Tracked files**
- Create: `infra/redis-lab/config/redis.conf.tmpl`
- Create: `infra/redis-lab/config/sentinel.conf.tmpl`
- Create: `infra/redis-lab/config/redis-users.acl.tmpl`
- Create: `infra/redis-lab/config/sentinel-users.acl.tmpl`
- Create: `infra/redis-lab/k3s/namespace.yaml`
- Create: `infra/redis-lab/k3s/redis-data.yaml`
- Create: `infra/redis-lab/k3s/redis-sentinel.yaml`
- Create: `infra/redis-lab/k3s/network-policy.yaml`
- Create:
`src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLabManifestContractTest.java`
- Modify: Redis Gradle manifest contract task
- static contract와 live security evidence를 분리한다. YAML/템플릿 정적 통과는 TLS handshake,
ACL authorization, CNI enforcement, scheduling/failover의 실행 증거가 아니다.
- data Redis 3개와 Sentinel 3개는 각각 stable ordinal/headless DNS가 필요한 StatefulSet으로
구성하고 `kubernetes.io/hostname` required anti-affinity와 `maxSkew=1/DoNotSchedule`
topology spread, `podManagementPolicy: Parallel`을 적용한다.
- data는 PVC + AOF `appendfsync everysec`를 사용한다. Sentinel은 공식 동작상 writable config에
discovery/failover 상태를 rewrite하므로, bootstrap source를 pod별 writable PVC config로
최초 1회 atomic init-copy하고 restart 때 기존 rewritten config를 덮어쓰지 않는다.
비어 있거나 손상된 기존 config는 자동 복구로 덮지 않고 startup을 실패시킨다.
- Redis image SSOT는 `src/gradle/redis-test-images.properties`
`redis.minimum.image` exact tag+digest다. `redis.approved.image`나 임의 YAML image를 이
minimum-version Sentinel slice에 섞지 않는다.
- plaintext port는 data/Sentinel 모두 0이고 TLS port만 연다. `tls-replication yes`,
hostname resolution/announcement와 certificate SAN용 stable DNS를 사용한다. data plane과
Sentinel plane은 서로 다른 CA/leaf material을 가지며, peer 연결에 필요한 root만 명시적
trust bundle로 교차 포함한다.
- ACL identity를 하나의 `redis-user`로 합치지 않는다.
- application data user: 선택 capability/program command/key/channel만;
- replica user: `+psync +replconf +ping`;
- Sentinel-to-data user: 공식 최소 Sentinel control command/channel set;
- Sentinel peer user: Sentinel 간 통신에 필요한 동일 superuser credential;
- application Sentinel discovery user: auth/hello/ping/role과 allowlisted read-only
`SENTINEL` subcommand만.
default user는 off이며 application/data/discovery user에 `+@all`, `allkeys`,
`allchannels`를 주지 않는다.
- Redis data ACL과 Sentinel ACL은 별도 template/projection이다. Sentinel peer superuser가
data Redis에, data capability user가 Sentinel에 존재하면 static contract가 실패한다.
- Secret/CA/private key/rendered config는 run별 `umask 077` 아래 생성하고 tracked manifest에는
Secret value, PEM, password가 없다. probe/command line에 `--pass`를 쓰지 않는다.
- exec probe를 사용해 kubelet source CIDR 예외를 만들지 않는다. default-deny ingress/egress
뒤 data 6379, Sentinel 26379, kube-dns와 exact qualification/application pod selector만
허용한다.
- Service는 headless/ClusterIP만, PDB는 data/Sentinel 각각 `minAvailable: 2`, container는
non-root, read-only root filesystem, privilege escalation false, capabilities drop ALL,
seccomp RuntimeDefault, explicit requests/limits를 요구한다.
- structural positive test와 한 필드씩 제거/변조한 mutation-negative fixture가
anti-affinity, spread, PDB, probes, TLS-only, ACL separation, Secret reference,
NetworkPolicy, image SSOT를 실제로 fail시키는지 검증한다.
- `hostPath`, `hostNetwork`, `hostPID`, `hostIPC`, privileged, NodePort, LoadBalancer,
tracked Secret data/stringData/PEM과 implicit latest image를 거절한다.
- static validator는 exact document inventory, duplicate YAML key/identity, selector/template
일치, exact NetworkPolicy edge graph를 검증한다. 정적 ordinal bootstrap은 최초
`redis-data-0` primary와 두 replica만 증명하며, failover 뒤 old-primary 재합류와 stale
direct write 차단은 live gate에 남긴다.
### Task 11.2B — Sentinel workload와 live security baseline GREEN
- Redis primary 1 + replica 2와 Sentinel 3/quorum 2를 세 node에 분산한다.
- anti-affinity/topology spread, PDB, NetworkPolicy, separate data/Sentinel CA와 named ACL을
적용한다.
- secret/certificate/k3s token은 매 run `umask 077` transient material로 생성하고 tracked
manifest에는 값/PEM을 넣지 않는다. Sentinel bootstrap config는 Secret volume에서 pod별
writable PVC로 최초 1회 atomic init-copy하며, 기존 rewritten config를 덮어쓰지 않는다.
- Redis image는 `redis.minimum.image` exact image/digest를 render하고 실제 pod image
ID/digest가 일치하는지 수집한다.
- data credential/CA로 Sentinel discovery가 실패하고 Sentinel material로 data command가
실패하는 negative test, untrusted CA/hostname mismatch/plaintext rejection을 실행한다.
- `SENTINEL CKQUORUM`, writable config rewrite/restart, exact 3 Ready placement, PDB,
default-deny/explicit-allow NetworkPolicy enforcement를 live k3s에서 검증한다.
- failover 중 죽어 있던 old primary가 재합류할 때 readiness가 stale direct write를 허용하지
않고 새 primary의 replica로 수렴하는지 live 검증한다.
### Task 11.3 — Sentinel client runtime TDD
- current `UnsupportedOperationException`을 먼저 고정하는 test를 quorum-consistent discovery와
분리된 discovery/data material contract로 교체한다.
- 2-of-3 Sentinel이 같은 primary를 보고할 때만 후보를 만들고 loopback/wildcard/unexpected
endpoint를 거절한다.
- active Sentinel role이 있을 때만 registry당 daemon worker 1개, role당 fixed-delay task 1개를
만들고 `sentinel-discovery-refresh-period`(기본 30초, 5초..5분)를 적용한다.
- scheduled poll과 command failure-triggered immediate rediscovery는 role별 같은 single-flight를
공유한다. `snapshot()`은 보조 trigger일 뿐 정상 polling을 대신하지 않는다.
- 정상 poll은 Sentinel material만 해석하고 현재 route identity와 같으면 data material/client를
만들지 않는다. 바뀐 quorum-approved endpoint에만 data candidate를 연다.
- command failure listener는 route lease 반환 뒤 topology/connectivity `UNAVAILABLE`에만
동작하며 listener 실패가 원래 certainty를 덮어쓰지 않는다.
- 새 data runtime은 version/program/semantic readiness를 통과한 뒤 router에 install한다.
- opaque route identity와 monotonic generation token으로 stale/same-primary candidate를
거절하고, install된 경우 old runtime은 new admission을 닫고 bounded drain/close한다.
- close는 task/worker를 bounded 종료하고 late candidate를 install하지 않고 정확히 한 번 닫는다.
- mutation을 자동 replay하지 않고 실행 여부가 불명확하면 `INDETERMINATE`를 보존한다.
### Task 11.4 — Multi-pod normal/failover qualification
1. host/lab preflight와 3 node/Sentinel quorum readiness를 수집한다.
2. 서로 다른 application pod에서 rate limit evaluation replay, idempotency
claim/start/renew/complete, session create/read/touch/rotate/revoke를 검증한다.
3. current primary pod를 kill하고 readiness unavailable timestamp를 기록한다.
4. Sentinel quorum election, client rediscovery, runtime generation swap/drain, semantic
readiness recovery를 실제 순서대로 기록한다.
5. election 60초, 추가 rediscovery/swap 30초, 총 recovery 90초의 regression limit을 적용한다.
6. rate state가 조용히 reset되지 않고 idempotency owner/terminal 결과가 중복되지 않으며
confirmed session state가 유지되는지 확인한다.
7. old primary의 replica 재합류와 모든 actor의 동일 generation 관측을 확인한다.
correctness role에는 bounded `min-replicas-to-write`/`min-replicas-max-lag`와 명시적 replica
acknowledgement policy를 사용한다. zero-data-loss/strong consistency를 주장하지 않으며
response-only cut 등 실행 여부가 불확실한 mutation은 `INDETERMINATE`이고 blind retry하지 않는다.
### Task 11.5 — Evidence와 exact teardown
- actual image digest/image ID, config/program digest, sanitized fault/election/recovery timeline,
capability별 outcome/certainty, Kubernetes/Sentinel 관측을 allowlist schema로 생성한다.
- `NOT_CAPTURED` placeholder는 qualification 성공으로 인정하지 않는다.
- sanitizer/reconciler 성공 뒤에도 human clean commit/remote CI 전에는
`releaseQualification=NOT_CLAIMED`를 유지한다.
- 성공/실패 모두 exact VM allowlist를 teardown하고 lab resource가 0인지 확인한다. local
retain-on-failure opt-in은 명시된 경우만 허용하고 CI에서는 금지한다.
## Task 12 — CI, runbook, verification와 Wiki capture
**CI**
- PR blocking `redis-standalone` job을 `release-gate.needs`와 result loop에 실제 포함한다.
- nightly/release Redis production readiness workflow를 추가한다.
- workflow contract test로 blocking job/aggregator 집합 동등성을 검증한다.
**Verification**
```bash
cd src
./gradlew :application-core:redisPolicyContractTest --console=plain
./gradlew :shared-contract:edgeRateLimitContractTest --console=plain
./gradlew :adapter:outbound:cache-redis:check --console=plain
./gradlew :app-bootstrap:redisCompositionTest --console=plain
./gradlew redisProductionReadiness --console=plain
./gradlew test --console=plain
./gradlew check --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew verifyPublicPathSnapshot --console=plain
./gradlew verifyEnvKeys --console=plain
```
**Documentation**
- capability별 실제 readiness와 남은 R3 한계를 README/spec/runbook에 동기화한다.
- 실행 명령, image/config/program digest, 실패/차단을 public LLM Wiki
`/home/donghyeon/workspace/ai-tools/llm-wiki/raw/branch-notes/main.md`
기록하고 실제 파생 오류/면접/블로그 raw 문서를 양방향 링크한다.
**Completion gate**
- Task 11의 exit gate를 통과하면 `Sentinel-first R2-ready candidate`라고만 보고한다.
- clean committed source와 실제 remote CI가 없으면 selected/R2로 승격하지 않는다.
- 이 milestone 보고 뒤 멈추고 Cluster/R3/fenced coordination 또는 fileserver/HTTP client 중
다음 작업을 사용자와 다시 정한다.
## Task 13 — Resume blocker: selection-driven role activation과 default boot
**Problem**
- provider definition뿐 아니라 role binding도 capability가 선택되지 않으면 inert여야 한다.
- 현재 구현은 role binding 전체를 runtime으로 열고 health contributor도 role property 존재만으로
활성화한다.
- local 기본값에서 inbound rate-limit은 provider 없이 활성화되면 안 된다.
**Tests first**
- CACHE/COORDINATION/SESSION deployment와 role을 모두 사전 선언해도 cache/rate/idempotency/lease/
session capability가 비활성이면 credential/trust resolution, native client, scheduler/subscriber,
Redis health contributor가 모두 0이다.
- 각 capability가 `redis`를 선택할 때만 해당 role이 활성화된다.
- 같은 role을 쓰는 coordination capability 둘 이상은 하나의 runtime만 공유한다.
- 선택 capability의 role binding이 빠지면 material resolution 전에 startup이 실패한다.
- shipped `.env`와 실제 `application.yml`은 transport disabled/provider disabled 조합으로 기동
가능하고 중복 legacy rate-limit block이 없다.
**Implementation**
- deployment/role registry validation과 runtime activation을 분리한다.
- `selectedCapabilities`가 비어 있는 role은 registry/router/health에서 제외한다.
- bootstrap health condition도 role property가 아니라 effective selected capability로 판단한다.
- provider 설정은 inert 후보로 남기되 선택된 capability의 잘못된 role은 fail closed 한다.
## Task 14 — Resume blocker: capability-aware semantic readiness
**Problem**
- PING만으로 `AVAILABLE/PROBE_SUCCEEDED`를 선언하지 않는다.
- required coordination/session은 실제 선택 capability의 program ACL과 최소 read/write 계약이
동작해야 ready다.
**Tests first**
- PING은 성공하지만 `SCRIPT LOAD`/`EVALSHA`가 ACL로 거절된 coordination/session user는
`redisRequired=DOWN`이다.
- capability별 representative program의 실제 key count와 command-to-key mapping을 그대로
검증한다. rate-limit의 state/dedup/order key와 session tombstone key 중 하나만 ACL pattern에서
빠져도 semantic readiness는 실패한다.
- Redis 7.2 미만 server는 metadata 표기만으로 통과하지 않고 bounded runtime handshake에서
sanitized unsupported-version 상태가 된다.
- 대표 program과 ACL probe script가 이미 warm인 상태에서도 runtime user의 `SCRIPT LOAD`
권한 누락을 별도로 탐지한다.
- cache optional role에서 semantic probe 실패는 application liveness/readiness를 내리지 않고
`DEGRADED`만 보고한다.
- 선언된 optional cache가 cold-start connect/PING에 일시 실패해도 context는 bounded unavailable
route로 시작하고, health-triggered bounded single-flight reconnect 뒤 재시작 없이 복구한다.
invalid configuration/material/program/schema는 계속 startup failure이며 required
coordination/session은 fail closed다.
- probe는 raw key/value, credential, server exception을 health detail에 노출하지 않는다.
- probe key는 bounded, namespaced, TTL이 있고 성공/실패 후 잔여 상태가 없다.
- saturation/recent command failure/closed route를 distinct sanitized reason으로 분류한다.
- health scrape는 role별 minimum cadence와 single-flight로 full semantic suite 실행을 제한하고,
cached observation의 시각/age를 노출해 stale success를 숨기지 않는다.
**Implementation**
- role별 선택 capability를 입력으로 immutable semantic probe plan을 만든다.
- probe는 catalog-owned bounded program과 capability-safe ephemeral operation만 사용한다.
- optional cold-start outage는 resource-free unavailable runtime과 bounded on-demand reconnect로
표현하며 별도 unbounded scheduler/thread를 만들지 않는다. L1 invalidation subscription은
route recovery 시 실제 runtime에 다시 연결된다.
- eviction은 runtime `CONFIG` 권한을 열지 않고 `CONFIGURED_EXPECTATION_ONLY`로 유지하며 외부
attestation 미완료를 readiness detail에 명시한다.
## Task 15 — Resume blocker: bounded common primitive catalog
**Problem**
- Deep design §14.6–§14.9의 자주 쓰는 race-safe helper가 아직 compare/delete 중심 R0 foundation에
머물러 있다.
**Tests first**
- String, counter, hash, set, sorted-set, list baseline은 typed/versioned key, value/count/byte/deadline,
role, slot, TTL, certainty bound를 강제한다.
- bitmap/HLL/geo는 billing/auth correctness에 사용할 수 없는 explicit semantic classification과
offset/result/fan-in bound를 강제한다.
- `INCR -> EXPIRE`, set/list admission, revision-CAS는 실제 Redis concurrency에서 atomic하다.
- unbounded `HGETALL`, `SMEMBERS`, `LRANGE`, arbitrary command/script surface는 제공하지 않는다.
**Implementation**
- package-private `RedisPrimitiveCatalog`과 structure별 bounded facade를 Redis leaf 내부에 둔다.
- application/shared public API에는 Redis command나 raw key를 노출하지 않는다.
- 아직 실제 semantic consumer가 없는 primitive는 Spring bean/public capability로 노출하지 않는다.
## Task 16 — Resume blocker: capability observability와 graceful lifecycle
**Tests first**
- cache/rate/idempotency/lease/session의 operation, outcome, certainty, role, queue/latency가 bounded
low-cardinality metric/event로 관측된다.
- raw key, subject, session/idempotency/lease token, secret reference/value, exception message는
tag/log/trace에 들어가지 않는다.
- optional cache와 required coordination/session의 failure signal이 health와 metric에서 일치한다.
- shutdown은 subscriber/scheduler/router/runtime 순서로 bounded drain되고 새 command를 거절한다.
**Implementation**
- framework-neutral observation event/port와 Micrometer rendering을 계층 소유권에 맞게 둔다.
- trace/log는 기존 skeleton observability 경계를 재사용하고 Redis native type을 core에 유출하지
않는다.
- `docs/registries/metrics.yaml`과 runbook을 실제 emitted metric과 동기화한다.
## Task 17 — Resume final review, readiness truth, verification와 Wiki
- Task 1316을 task별 spec/code-quality review한다.
- Redis deep design §39/§40을 독립 재검토해 selected/implemented-candidate/not-implemented를 실제
evidence와 일치시킨다.
- Sentinel/Cluster/k3s/R3 evidence가 없으면 지원/완료로 표기하지 않는다.
- Task 12의 전체 검증을 실행하고 동시 작업의 비-Redis 실패는 소유 파일과 증거를 분리한다.
- Redis README/spec/runbook, readiness registry, CI artifact 계약을 동기화한다.
- LLM Wiki branch-note와 실제 파생 raw 문서를 양방향 링크로 캡처한다.
@@ -0,0 +1,241 @@
# Redis Lab Strict Kubeconfig Renderer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox
> (`- [ ]`) syntax for tracking.
**Goal:** Complete parent Task 11.1A by replacing mutation-by-mutation kubeconfig filtering with a
pinned-K3s, strict block-grammar validator/renderer and passing an independent safety review.
**Architecture:** Freeze the already-reviewed lifecycle/ownership state machine as Task 11.1A-1.
Move kubeconfig validation/rendering into one tracked AWK program, Task 11.1A-2. The program accepts
only the exact single-cluster/context/user block grammar emitted by the pinned K3s slice, transforms
only lab identity fields, and rejects every non-allowlisted structure before any lab `kubectl`
command.
**Tech Stack:** Bash 5 strict mode, POSIX-compatible AWK features already used by the repository,
the fake-command shell contract, Gradle 9, Java 21.
## Global Constraints
- Do not create a VM, run real Multipass/k3s/kubectl, inspect host inventory, or access the network.
- Do not modify Task 11.1A-1 ownership, state, signal, lock, cleanup or fingerprint behavior.
- Do not add `yq`, PyYAML, Ruby, Java YAML runtime, or another downloadable parser dependency.
- The only accepted source grammar is the pinned K3s admin kubeconfig block-style shape defined in
deep design §37.13.4.1.
- `preferences: {}` is the only permitted flow collection.
- Validation failure removes the destination, emits only `redis-lab: lab kubeconfig invalid`, and
occurs before lab `kubectl`.
- Preserve prior `CREATED|RECONCILE` state and delete only exact marker-proven current-run VMs.
- Tests must show RED against the current implementation before production changes.
- Human-only Git policy applies: do not stage, commit, amend or push.
---
### Task 1: Extract a strict generated-kubeconfig renderer
**Files:**
- Create: `infra/redis-lab/lib/render-kubeconfig.awk`
- Modify: `infra/redis-lab/bin/redis-lab`
- Modify: `infra/redis-lab/test/redis-lab-contract.sh`
**Interfaces:**
- Consumes: `awk -v address=<validated IPv4> -v target=ca-redis-lab -f <renderer> <source>`.
- Produces: rendered kubeconfig on stdout and exit `0`, or no accepted output and non-zero exit.
- Integration: `render_lab_kubeconfig <source> <destination> <server-address>` performs atomic
temporary render, mode `0600`, destination replacement only after renderer success.
- [x] **Step 1: Add realistic positive and sibling-flow RED fixtures**
Change the fake `valid` kubeconfig to this complete credential-data shape, using canary values
rather than real certificate material:
```yaml
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: preserve-default-ca-canary
server: https://127.0.0.1:6443
name: default
contexts:
- context:
cluster: default
namespace: team-default
user: default
name: default
current-context: default
kind: Config
preferences: {}
users:
- name: default
user:
client-certificate-data: preserve-default-client-cert-canary
client-key-data: preserve-default-client-key-canary
```
Add separate public `up` variants containing, after their canonical item:
```yaml
cluster : {server: https://foreign.invalid:6443}
```
and:
```yaml
context : {cluster: foreign, user: foreign}
```
Each variant must assert failure, zero lab `kubectl`, three exact marker-proven deletes, removed
rendered kubeconfig, and no forbidden fake invocation.
- [x] **Step 2: Run the direct contract and verify RED**
Run:
```bash
bash -n infra/redis-lab/bin/redis-lab infra/redis-lab/test/redis-lab-contract.sh
bash infra/redis-lab/test/redis-lab-contract.sh
```
Expected: syntax succeeds and the first new sibling-flow case fails because the current renderer
unexpectedly accepts it.
- [x] **Step 3: Implement the strict AWK state machine**
`render-kubeconfig.awk` must use an explicit `state` transition for every accepted line. It must
not print from a catch-all rule. The accepted transition sequence is:
```text
apiVersion -> clusters -> cluster-item -> ca-data -> server -> cluster-name
-> contexts -> context-item -> context-cluster -> optional-namespace -> context-user
-> context-name -> current-context -> kind -> preferences -> users -> user-name
-> user-body -> client-cert -> client-key -> EOF
```
Exact identity transitions print these replacements:
```awk
print " server: https://" address ":6443"
print " name: " target
print " cluster: " target
print " user: " target
print "current-context: " target
print "- name: " target
```
CA/client credential and namespace transitions print `$0` unchanged. Any unmatched line sets
`invalid=1`; `END` exits non-zero unless the final state is `client-key`, every required
transition occurred once, the input had no tab/CR/YAML marker, and no trailing line exists.
- [x] **Step 4: Integrate the renderer fail-closed**
Add:
```bash
KUBECONFIG_RENDERER="${REPOSITORY_ROOT}/infra/redis-lab/lib/render-kubeconfig.awk"
```
`validate_static_contract` must require a readable regular non-symlink renderer at that exact
canonical path. Replace the inline AWK body with:
```bash
local render_next="${destination_file}.next"
rm -f -- "${render_next}"
if ! awk -v address="${server_address}" -v target="${CONTEXT_NAME}" \
-f "${KUBECONFIG_RENDERER}" "${source_file}" >"${render_next}"; then
rm -f -- "${render_next}" "${destination_file}"
fail 'lab kubeconfig invalid'
return 1
fi
chmod 0600 -- "${render_next}"
mv -f -- "${render_next}" "${destination_file}"
```
Add the `.next` destination to symlink-child validation. Propagate `rm`, `chmod` and `mv`
failures with the same sanitized error and without retaining a partially accepted destination.
- [x] **Step 5: Run focused GREEN**
Run the direct contract again. Expected: `redis-lab-contract: PASS`, exit `0`.
### Task 2: Complete the mutation matrix and parent acceptance
**Files:**
- Modify: `infra/redis-lab/test/redis-lab-contract.sh`
- Modify: `infra/redis-lab/README.md`
- Modify: `docs/superpowers/plans/2026-07-29-redis-production-capability-completion.md`
- Modify:
`.superpowers/sdd/2026-07-29-redis-production-capability-completion/progress.md`
- Create:
`.superpowers/sdd/2026-07-29-redis-production-capability-completion/task-11-1a-2-brief.md`
- Create:
`.superpowers/sdd/2026-07-29-redis-production-capability-completion/task-11-1a-2-report.md`
**Interfaces:**
- Consumes: Task 1 strict renderer and existing lifecycle fake runtime.
- Produces: parent Task 11.1A review package with no open Critical/Important finding.
- [x] **Step 1: Add one mutation per grammar boundary**
Add table-driven fixture variants for missing, duplicate, reordered and unknown keys; whitespace
before colon; quoted/tagged/explicit keys; anchor/alias/merge; unexpected `{}`/`[]`; tab, CRLF,
`---`/`...`, and trailing content. Every case must assert failure before lab `kubectl`, exact
current-run cleanup and removed render output.
- [x] **Step 2: Prove scalar preservation and exact transformation**
The positive case must assert:
```text
server: https://192.0.2.10:6443
name/current-context: ca-redis-lab
namespace: team-default
preserve-default-ca-canary
preserve-default-client-cert-canary
preserve-default-client-key-canary
```
It must also assert that no `name: default`, `cluster: default`, `user: default`,
`current-context: default` or loopback server remains.
- [x] **Step 3: Re-run the full fake-only verification**
Run:
```bash
bash -n infra/redis-lab/bin/redis-lab infra/redis-lab/test/redis-lab-contract.sh
bash infra/redis-lab/test/redis-lab-contract.sh
cd src
./gradlew :adapter:outbound:cache-redis:redisLabContractTest --console=plain
./gradlew :adapter:outbound:cache-redis:test --console=plain
./gradlew :adapter:outbound:cache-redis:check --dry-run --console=plain
```
Expected: direct `PASS`; both Gradle executions `BUILD SUCCESSFUL`; dry-run includes
`redisLabContractTest`.
- [x] **Step 4: Run an independent scoped review**
Reviewer acceptance:
- strict renderer has no catch-all pass-through;
- the valid pinned fixture reaches EOF exactly once;
- every non-allowlisted structural line fails;
- destination publication is atomic/fail-closed;
- Task 11.1A-1 lifecycle code is unchanged except the renderer call and static path checks;
- Critical `0`, Important `0`, both spec and quality PASS.
- [x] **Step 5: Close the parent task**
Only after Step 4 passes, replace the ledger `BLOCKED` state with an additive resolution line:
```text
Task 11.1A-2: complete (human-only commit policy; strict renderer review clean)
Task 11.1A: complete (11.1A-1 lifecycle + 11.1A-2 renderer; fake-only evidence)
```
Do not claim live readiness, R2 or VM/k3s qualification.
@@ -1,12 +1,14 @@
# Fileserver Production Capability Deep Design # Fileserver Production Capability Deep Design
- 작성일: 2026-07-26 - 작성일: 2026-07-26
- 상태: 상세 설계 완료, Phase 01 및 Phase 2 일부 local R1 구현, R2 이상 미구현 - 상태: 상세 설계 완료, Phase 01 및 Phase 2 `local-persistent` R2 구현, 후속 provider/운영
capability 미구현
- 독립 아키텍처 재리뷰: blocker/high 0건 - 독립 아키텍처 재리뷰: blocker/high 0건
- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture - 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture
- 대상 leaf: `adapter-outbound-fileserver` - 대상 leaf: `adapter-outbound-fileserver`
- 구현 추적: 이 문서의 목표 전체가 아니라 framework-free port, local staged CSV, single-node - 구현 추적: 이 문서의 장기 목표 전체가 아니라 provider-neutral application/control 계약,
operation journal/recovery까지만 적용되었다. exact selector, pre-provisioned local filesystem을 위한 `local-persistent` R2 provider까지만
적용되었다.
- 상위 문서: - 상위 문서:
[Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md)
@@ -26,24 +28,45 @@
- operation-scoped JVM/OS file lock과 hard-link-only publication protocol; - operation-scoped JVM/OS file lock과 hard-link-only publication protocol;
- overwrite-capable legacy port의 별도 opt-in/root 및 canonical overlap 차단; - overwrite-capable legacy port의 별도 opt-in/root 및 canonical overlap 차단;
- 안전한 commit primitive가 없을 때 copy-to-final로 downgrade하지 않는 fail-closed 동작. - 안전한 commit primitive가 없을 때 copy-to-final로 downgrade하지 않는 fail-closed 동작.
- `app.fileserver` exact destination/provider selector와 producer 호출 전 unknown destination
거부;
- provider ID별 singleton runtime과 서로 다른 provider ID의 동일 normalized root 소유 거부;
- provider-neutral canonical operation v2/private manifest/reference index와 opaque
`fsr1.<route-token>.<file-id>.<check-digits>` direct lookup;
- strict UTF-8/canonical schema-v1 terminal record의 read-only compatibility와 schema-v2-only
write;
- absolute/pre-provisioned root, ancestor/root symlink, real path, owner/mode, FileStore
name/type, mount sentinel, `SecureDirectoryStream`, exclusive-create/hard-link/file·directory
force startup attestation;
- `WRITING -> SEALED -> DATA_PUBLISHED -> MANIFEST_PUBLISHED -> REFERENCE_PUBLISHED ->
PUBLISHED` durable publication ordering;
- data/manifest/reference/receipt 전체 교차검증과 deterministic resume/quarantine;
- terminal mismatch에서 journal과 모든 artifact를 불변 보존하는 fail-closed recovery;
- `FILE_AND_DIRECTORY_SYNC` receipt와 forked-process force-boundary/OS operation-lock
qualification seam;
- `app-bootstrap` opt-in composition과 disabled-default/no-filesystem-side-effect gating.
아직 구현되지 않은 범위: 아직 구현되지 않은 범위:
- Phase 2의 cross-node fencing, reference/private-manifest index, exhaustive crash/symlink-race - `shared-mounted`/NFS multi-client semantics와 cross-node producer fencing;
qualification; - 운영 background reconciliation/reaper, retention, quota/backpressure인 Phase 3;
- 운영 cleanup/quota/retention과 effective capability probe인 Phase 3; - Fileserver 전용 readiness/health, metrics, tracing, structured audit;
- SFTP provider인 Phase 4; - SFTP provider인 Phase 4;
- NFS/HA/bootstrap evidence인 Phase 5; - NFS/HA/operator topology evidence인 Phase 5;
- optional delete/read/scan operation인 Phase 6. - optional delete/read/scan operation인 Phase 6.
따라서 현재 journal은 single-node local recovery seam이며 Fileserver R2 완료 증거가 아니다. 따라서 현재 R2 claim은 `local-persistent`에만 한정한다. `FILE_AND_DIRECTORY_SYNC`는 attested
기존 `FileExportPort`도 호환성을 위해 filesystem 안에서 file과 관련 directory force가 성공했다는 뜻이며 physical device,
storage-controller cache, volume replica, backup/site의 power-loss protection을 뜻하지 않는다.
그 축은 deployment/storage evidence가 별도로 소유한다. 기존 `FileExportPort`도 호환성을 위해
남아 있으며, 전체 행 materialization과 absolute path receipt를 사용하는 legacy 경로다. 남아 있으며, 전체 행 materialization과 absolute path receipt를 사용하는 legacy 경로다.
기존 R1 terminal artifact는 strict read-only로 원래 `PROCESS_LOCAL_SYNC` receipt만 복원하고
manifest/reference 생성, schema-v2 rewrite, R2 guarantee 자동 승격을 하지 않는다.
## 1. 설계 판정 ## 1. 설계 판정
현재 Fileserver 구현은 운영 파일서버가 아니라 다음 한 경로만 제공하는 R1 이하의 로컬 설계 시작 당시 Fileserver 구현은 운영 파일서버가 아니라 다음 한 경로만 제공하는 R1 이하의
CSV 예제다. 로컬 CSV 예제였다. 현재의 increment 상태와 보장 경계는 §0을 따른다.
```text ```text
List<List<String>> List<List<String>>
@@ -109,7 +132,11 @@ List<List<String>>
이번 문서는 위 항목을 구현 계획을 작성할 수 있는 수준까지 확정한다. 이번 문서는 위 항목을 구현 계획을 작성할 수 있는 수준까지 확정한다.
## 3. 현재 코드의 증거 기반 진단 ## 3. 초기 코드의 증거 기반 진단
아래 표는 설계가 시작된 2026-07-26의 baseline을 보존한 역사적 진단이다. 현재 구현 상태는
§0이 권위이며, 아래 결함 중 streaming/opaque receipt/exclusive publication/control plane/local
attestation/composition은 후속 increment에서 해소되었다.
| 영역 | 현재 구현 | 운영상 의미 | | 영역 | 현재 구현 | 운영상 의미 |
| --- | --- | --- | | --- | --- | --- |
@@ -134,13 +161,14 @@ List<List<String>>
- `src/application-core/src/main/java/dev/caskeleton/application/fileexport/FileExportPort.java` - `src/application-core/src/main/java/dev/caskeleton/application/fileexport/FileExportPort.java`
- `src/application-core/src/main/java/dev/caskeleton/application/fileexport/ExportedFile.java` - `src/application-core/src/main/java/dev/caskeleton/application/fileexport/ExportedFile.java`
- `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java` - `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java`
- `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java` - `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java`
- `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java` - `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java`
- `src/config/architecture/modules.json` - `src/config/architecture/modules.json`
- `src/app-bootstrap/build.gradle` - `src/app-bootstrap/build.gradle`
현재 7개 fileserver unit test와 leaf `check`는 성공한다. 이는 현재 문서화된 로컬 happy-path 당시 7개 fileserver unit test와 leaf `check` 성공은 로컬 happy-path만 증명했다. 현재의
계약이 동작한다는 증거일 뿐 production readiness 증거는 아니다. `local-persistent` claim은 별도 root attestation, control/payload/recovery, forked crash와
cross-process OS lock qualification suite의 통과를 요구한다.
## 4. 범위와 명시적 비범위 ## 4. 범위와 명시적 비범위
@@ -1927,22 +1955,26 @@ ca-skeleton:
`docs/registries/env-keys.yaml`, `application.yml`, typed settings, conditional beans를 end-to-end `docs/registries/env-keys.yaml`, `application.yml`, typed settings, conditional beans를 end-to-end
검증한다. 검증한다.
Template baseline에 필요한 key: 현재 구현된 `local-persistent` composition에 등록하는 key:
```text ```text
APP_FILESERVER_PRIMARY_ROOT APP_FILESERVER_ENABLED
APP_FILESERVER_PRIMARY_MOUNT_ID APP_FILESERVER_LOCAL_ROOT
APP_FILESERVER_SFTP_HOST APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME
APP_FILESERVER_SFTP_USERNAME APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE
APP_FILESERVER_SFTP_PRIVATE_KEY_SECRET_REF APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256
APP_FILESERVER_SFTP_KNOWN_HOSTS_SECRET_REF APP_FILESERVER_LOCAL_EXPECTED_OWNER
APP_FILESERVER_SFTP_CONTROL_ROOT
APP_FILESERVER_SFTP_SPOOL_ROOT
APP_FILESERVER_SECRET_CONFIG_ROOT
``` ```
모두 restart-only다. `APP_FILESERVER_ENABLED=false`가 shipped default이며, 나머지 다섯
attestation 값은 `app.fileserver.enabled=true`일 때 모두 필요하다. Root는 absolute/existing
directory, FileStore name/type과 owner는 non-blank exact match, sentinel digest는 64-character
lowercase SHA-256여야 한다.
Dynamic destination topology는 YAML/config tree가 소유하고 secret value는 secret source가 Dynamic destination topology는 YAML/config tree가 소유하고 secret value는 secret source가
제공한다. 제공한다. 앞의 broader topology 예시에 있는 SFTP/NFS key는 아직 env registry나 shipped
`application.yml`에 등록하지 않는다. 실제 provider, dependency, real-service qualification이
추가되는 후속 increment에서만 등록한다.
## 24. Health와 observability ## 24. Health와 observability
@@ -2228,9 +2260,10 @@ Nightly:
### 27.1 Dependency ownership ### 27.1 Dependency ownership
현재 가장 가까운 `src/adapter/outbound/fileserver/CLAUDE.md`는 pure JDK, external dependency 현재 가장 가까운 `src/adapter/outbound/fileserver/CLAUDE.md`는 pure JDK filesystem과 Spring
없음, NFS/SFTP stand-in만을 허용한다. 따라서 이 문서만으로 SFTP SDK를 Gradle에 추가할 수 configuration baseline만 허용하고 `local-persistent`만 구현 대상으로 인정한다. NFS/SFTP
없다. 구현 Phase 0에서 아키텍처 승인과 함께 다음 rule drift를 먼저 갱신한다. stand-in이나 SDK는 허용하지 않는다. 따라서 이 문서만으로 SFTP SDK를 Gradle에 추가할 수 없다.
후속 SFTP 구현에서는 아키텍처 승인과 함께 다음 rule drift를 먼저 갱신한다.
- local `CLAUDE.md`의 책임을 local-only demo에서 provider-based publication으로 변경; - local `CLAUDE.md`의 책임을 local-only demo에서 provider-based publication으로 변경;
- external `NONE` 규칙을 exact allowlist로 변경; - external `NONE` 규칙을 exact allowlist로 변경;
@@ -2240,7 +2273,7 @@ Nightly:
이 rule migration 전 SFTP dependency 추가나 runtime activation은 HARD-STOP이다. 이 rule migration 전 SFTP dependency 추가나 runtime activation은 HARD-STOP이다.
`adapter-outbound-fileserver`: 후속 provider rule migration의 후보 allowlist이며 현재 dependency가 아니다:
- JDK NIO local/mounted provider; - JDK NIO local/mounted provider;
- Spring autoconfigure; - Spring autoconfigure;
@@ -2260,7 +2293,8 @@ starter를 추가하지 않는다.
### 27.2 Bootstrap composition ### 27.2 Bootstrap composition
안전한 explicit binding/gating과 config test가 먼저 구현된 후: `local-persistent`에 대한 안전한 explicit binding/gating과 config test가 구현되었고 다음
composition을 적용했다.
1. `modules.json`의 `app-bootstrap.allowed_dependencies`에 1. `modules.json`의 `app-bootstrap.allowed_dependencies`에
`adapter-outbound-fileserver` 추가; `adapter-outbound-fileserver` 추가;
@@ -2271,7 +2305,8 @@ starter를 추가하지 않는다.
6. disabled-adapter architecture scan에 fileserver 추가; 6. disabled-adapter architecture scan에 fileserver 추가;
7. env/settings/readiness contract 추가. 7. env/settings/readiness contract 추가.
Classpath에 들어왔다는 이유로 local provider가 활성화되면 안 된다. `application.yml`의 `app.fileserver.enabled=false`가 shipped default다. Classpath에 들어왔다는
이유만으로 local provider가 활성화되거나 filesystem side effect가 발생하지 않는다.
### 27.3 SDK split trigger ### 27.3 SDK split trigger
@@ -2287,9 +2322,11 @@ Classpath에 들어왔다는 이유로 local provider가 활성화되면 안 된
### Phase 0 — Truthful topology와 contract freeze ### Phase 0 — Truthful topology와 contract freeze
- 현재 Fileserver를 R1 local CSV demo로 명시; 상태: 완료. 현재 문서는 provider별 구현 상태와 보장 경계를 분리한다.
- 초기 Fileserver를 R1 local CSV demo로 명시하고 후속 R2 범위를 분리;
- Fileserver `CLAUDE.md`와 README의 responsibility/dependency/registry SSOT drift 수정; - Fileserver `CLAUDE.md`와 README의 responsibility/dependency/registry SSOT drift 수정;
- current bootstrap 미합성 상태 명시; - 초기 bootstrap 미합성 상태와 후속 disabled-default opt-in composition을 함께 기록;
- v2 contract와 error registry 승인; - v2 contract와 error registry 승인;
- journal/reference/control-plane schema 승인; - journal/reference/control-plane schema 승인;
- accepted-attempt와 global coordination guarantee 분리; - accepted-attempt와 global coordination guarantee 분리;
@@ -2306,6 +2343,8 @@ Acceptance:
### Phase 1 — Streaming application contract와 CSV ### Phase 1 — Streaming application contract와 CSV
상태: 완료. Framework-free `FilePublicationPort`와 bounded streaming CSV 경로가 구현되었다.
- `FilePublicationPort`; - `FilePublicationPort`;
- operation ID/fingerprint; - operation ID/fingerprint;
- effective policy snapshot; - effective policy snapshot;
@@ -2321,6 +2360,9 @@ Acceptance:
### Phase 2 — Secure local/mounted publication ### Phase 2 — Secure local/mounted publication
상태: `local-persistent` 완료. `shared-mounted`/NFS multi-client profile과 cross-node fencing은
미구현이다.
- staging; - staging;
- digest/manifest; - digest/manifest;
- sealed journal과 protocol별 artifact ordering; - sealed journal과 protocol별 artifact ordering;
@@ -2336,6 +2378,8 @@ Acceptance:
### Phase 3 — Resource/maintenance/observability ### Phase 3 — Resource/maintenance/observability
상태: 미구현.
- concurrency/byte quota; - concurrency/byte quota;
- timeout/cancel/shutdown; - timeout/cancel/shutdown;
- staging reaper/report; - staging reaper/report;
@@ -2348,6 +2392,8 @@ Acceptance:
### Phase 4 — SFTP provider ### Phase 4 — SFTP provider
상태: 미구현. SFTP setting/env/dependency/bean도 등록하지 않는다.
- Spring Integration/Apache MINA; - Spring Integration/Apache MINA;
- host key/secrets; - host key/secrets;
- bounded pool/timeouts; - bounded pool/timeouts;
@@ -2362,6 +2408,9 @@ Acceptance:
### Phase 5 — NFS/HA evidence와 bootstrap ### Phase 5 — NFS/HA evidence와 bootstrap
상태: `app-bootstrap`의 disabled-default opt-in composition과 local env mapping만 완료.
NFS/HA/operator topology evidence는 미구현이다.
- multi-client NFS profile; - multi-client NFS profile;
- operator attestation; - operator attestation;
- app-bootstrap composition; - app-bootstrap composition;
@@ -2374,6 +2423,8 @@ Acceptance:
### Phase 6 — Optional read/delete와 module split review ### Phase 6 — Optional read/delete와 module split review
상태: 미구현.
- opaque content transfer; - opaque content transfer;
- expected-version managed delete; - expected-version managed delete;
- provider split 조건 재평가; - provider split 조건 재평가;
@@ -2381,6 +2432,9 @@ Acceptance:
## 29. 완료 기준 ## 29. 완료 기준
아래는 이 장기 설계 전체의 완료 기준이며 현재 충족되지 않았다. 현재 완료 claim은 §0의
`local-persistent` R2 범위로 제한한다.
Fileserver R2 완료를 주장하려면: Fileserver R2 완료를 주장하려면:
- application contract에 path/provider/SDK가 없음; - application contract에 path/provider/SDK가 없음;
@@ -949,6 +949,13 @@ and durable interfaces are explicit.
### 13.3 Object storage ### 13.3 Object storage
The authoritative implementation-level design for this capability is
[Object Storage Production Capability Deep Design](2026-07-28-objectstorage-production-capability-design.md).
Its ordered REDGREEN execution batches and promotion gates are in the
[Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md).
This subsection is only the cross-capability baseline; the dedicated design governs when details
differ.
Replace whole-object `byte[]` as the only path with: Replace whole-object `byte[]` as the only path with:
- streaming upload/download and range reads; - streaming upload/download and range reads;
@@ -1,7 +1,7 @@
# Redis Production Capability Deep Design # Redis Production Capability Deep Design
- Date: 2026-07-26 - Date: 2026-07-26
- Status: 상세 설계 완료, Phase 0 및 Phase 1 일부 standalone R1 구현, R2 미구현 - Status: 상세 설계 완료, 5개 standalone `implemented-candidate`, selected/R2 없음
- Scope: Redis 전용 production capability와 단계적 구현 설계 - Scope: Redis 전용 production capability와 단계적 구현 설계
- Baseline: Java 21, Spring Boot 4.0.0, Gradle multi-module Clean Architecture template - Baseline: Java 21, Spring Boot 4.0.0, Gradle multi-module Clean Architecture template
- Parent: - Parent:
@@ -9,7 +9,7 @@
## 0. 구현 상태 ## 0. 구현 상태
2026-07-28 기준 구현된 범위: 2026-07-30 기준 구현된 범위:
- `application-core`의 provider-neutral `CacheRegionPort`와 hit/negative/miss/schema/unavailable - `application-core`의 provider-neutral `CacheRegionPort`와 hit/negative/miss/schema/unavailable
결과 구분; 결과 구분;
@@ -21,9 +21,17 @@
- generic application API가 아닌 package-private `RedisAtomicPrimitives` internal R0 foundation과 - generic application API가 아닌 package-private `RedisAtomicPrimitives` internal R0 foundation과
compatibility failure; compatibility failure;
- managed Lettuce standalone connection lifecycle과 finite command timeout; - managed Lettuce standalone connection lifecycle과 finite command timeout;
- `EVALSHA` 우선, 정확한 `NOSCRIPT`에만 `EVAL` fallback하는 production executor; - `EVALSHA` 우선, 정확한 `NOSCRIPT`에만 catalog script를 `SCRIPT LOAD`하고 digest를 검증한 뒤
`EVALSHA`를 한 번 재시도하는 production executor;
- versioned digest-protected bounded binary cache envelope, positive/negative TTL, invalidate와 - versioned digest-protected bounded binary cache envelope, positive/negative TTL, invalidate와
corrupt/future/unavailable 구분을 제공하는 `CacheRegionPort<String,String>` reference adapter; corrupt/future/unavailable 구분을 제공하는 `CacheRegionPort<String,String>` reference adapter;
- envelope v2의 absolute soft/hard expiry, injected clock freshness 판정, deterministic
policy-revision/key jitter, hard minimum과 physical Redis TTL 일치;
- framework-free `CacheAsideExecutor`와 typed source/result/cancellation contract;
- maximum in-flight key/waiter/source concurrency/admission/load deadline을 제한하는 local
single-flight와 source bulkhead, abandoned-flight opportunistic reaping;
- authoritative absence만 negative-cache하고 classified transient failure에만 hard-expiry 전
stale fallback을 허용하는 application policy;
- HMAC key secret/namespace/value bound typed settings와 disabled zero-connection composition; - HMAC key secret/namespace/value bound typed settings와 disabled zero-connection composition;
- `managed`/`external` client mode를 통한 결정적 runtime 선택; - `managed`/`external` client mode를 통한 결정적 runtime 선택;
- reconnect command replay 차단, finite Lettuce request queue와 client-side admission; - reconnect command replay 차단, finite Lettuce request queue와 client-side admission;
@@ -32,27 +40,44 @@
- managed runtime 활성화 시 Redis host 누락을 `localhost`로 숨기지 않는 startup fail-fast; - managed runtime 활성화 시 Redis host 누락을 `localhost`로 숨기지 않는 startup fail-fast;
- generic Lua executor/descriptor와 raw-key typed primitive를 package-private collaborator로 - generic Lua executor/descriptor와 raw-key typed primitive를 package-private collaborator로
닫고 Spring composition에는 semantic cache port만 노출; 닫고 Spring composition에는 semantic cache port만 노출;
- 명시적 Redis 7.4 standalone service lane의 실제 TTL expiry, compare-delete Lua, - 명시적 Redis 7.2/7.4 standalone service lane의 실제 TTL expiry, compare-delete Lua,
oversized bulk-reply 차단 검증. oversized bulk-reply 차단 검증;
- `shared-contract`의 provider-neutral edge rate-limit request/policy/decision/outcome/port;
- fixed window, sliding-window counter, token bucket의 versioned one-key Lua와 bounded
structured MULTI reply parser;
- private HMAC key, Redis server time, clock regression clamp, denial-no-consume, finite state
TTL과 pre-send/post-dispatch failure certainty를 보존하는 semantic provider;
- cache와 endpoint/connection/admission/settings를 공유하지 않는 coordination-role 전용
`app.rate-limit` composition과 disabled zero-side-effect gating;
- 세 알고리즘을 실제 standalone Redis에 실행하도록 선택 가능한 service qualification lane;
- request-replay idempotency, cache refresh soft lease, versioned session repository semantic
provider와 각 card-owned standalone/security/fault/compatibility evidence;
- cache generation/revision invalidation, bounded local L1, authenticated invalidation hint,
semantic health/metrics와 standalone TLS+named ACL evidence.
아직 구현되지 않은 범위: 아직 구현되지 않은 범위:
- cache jitter, soft/hard TTL, cache-aside/single-flight/source bulkhead; - refresh-ahead와 probabilistic early refresh;
- Redis Functions 배포와 program upgrade/rollback compatibility matrix; - Redis Functions 배포와 program upgrade/rollback compatibility matrix;
- health/metrics/TLS/ACL/secret/topology/eviction 검증; - Sentinel runtime, Cluster production qualification, k3s/multi-node/failover/rotation,
- distributed rate limit, idempotency, lease/fencing, session; effective eviction/persistence attestation;
- fenced coordination과 multi-process/pod session 및 L1/L2 distributed qualification;
- Phase 1의 전체 acceptance와 R2/R3 승격 증거. - Phase 1의 전체 acceptance와 R2/R3 승격 증거.
따라서 standalone runtime/string cache는 R1 evidence를 가지지만 Redis capability 전체 또는 현재 registry의 cache, edge rate limit, request-replay idempotency, cache refresh soft lease,
어떤 production topology도 R2가 아니다. raw-key Lua foundation session card는 standalone promotion topology`implemented-candidate`다. fenced coordination
rate/idempotency/lease/session은 semantic composition이 없어 여전히 R0다. `not-implemented`다. `implemented-candidate`는 구현과 card-owned evidence lane을 뜻할 뿐 release
selection이나 R2 qualification이 아니다. checked-in `selected` card가 0개이므로 Redis capability
전체 또는 어떤 production topology에도 R2 release claim을 하지 않는다.
## 1. 설계 판정 ## 1. 설계 판정
설계 착수 당시 `adapter:outbound:cache-redis`는 실제 Redis client, connection, topology, TTL, 설계 착수 당시 `adapter:outbound:cache-redis`는 실제 Redis client, connection, topology, TTL,
codec, atomic program, failure semantics가 없는 R0 extension seam이었다. 2026-07-28 구현으로 codec, atomic program, failure semantics가 없는 R0 extension seam이었다. 2026-07-30 현재 위 5개
standalone managed Lettuce runtime과 semantic string cache는 R1까지 올라왔지만, topology, semantic provider는 standalone `implemented-candidate`이며 standalone TLS+named ACL과 bounded
TLS/ACL, restart/fault/eviction evidence가 없으므로 여전히 production-ready adapter는 아니다. fault evidence도 있다. 그러나 selection, Sentinel/Cluster, multi-node/failover/rotation,
effective eviction/persistence attestation과 R3 증거가 없으므로 production-ready/R2라는 단일
label을 붙이지 않는다.
이번 설계는 다음 구조를 선택한다. 이번 설계는 다음 구조를 선택한다.
@@ -75,16 +100,16 @@ TLS/ACL, restart/fault/eviction evidence가 없으므로 여전히 production-re
| Capability | 현재 | 목표 | | Capability | 현재 | 목표 |
| --- | --- | --- | | --- | --- | --- |
| Redis runtime | managed Lettuce standalone R1 + explicit external-client mode | Spring Data Redis + Lettuce 기반 typed runtime | | Redis runtime | canonical role router와 managed/external Lettuce runtime, standalone candidate | Sentinel runtime과 Cluster production qualification |
| Cache | `Optional<String> get`, `void put` | typed region, TTL, negative/stale, invalidate, cache-aside | | Cache | standalone `implemented-candidate`; generation/soft lease/bounded L1과 TLS/ACL/fault lane | multi-process L1/L2와 HA/persistence/eviction attestation |
| Rate limit | inbound-web single-node fixed window | policy별 fixed/sliding/token/GCRA Redis provider | | Rate limit | fixed/sliding-counter/token-bucket standalone `implemented-candidate` | HA topology, failover와 R3 evidence |
| Idempotency | JPA 전제, owner token 없음 | atomic claim, owner-safe complete, execution/replay TTL 분리 | | Idempotency | owner-safe Redis V2 standalone `implemented-candidate`; JDBC provider와 명시적 선택 | actual-used image/event evidence와 selected promotion |
| Lock | JDBC efficiency lock | Redis efficiency lease + 별도 fenced contract | | Lock | Redis efficiency lease candidate; fenced coordination은 `not-implemented` | protected-resource stale fencing-token rejection |
| Session | JWT stateless 고정 | JWT 또는 isolated Redis Session의 명시적 profile | | Session | JWT isolated Redis Session profile; Redis는 standalone `implemented-candidate` | multi-process/pod와 failover/rotation qualification |
| Atomic helper | 없음 | versioned Function/Lua program registry | | Atomic helper | versioned closed Lua catalog와 typed internal facade | Redis Functions upgrade/rollback matrix |
| Topology | 없음 | standalone, Sentinel, Cluster의 typed exclusive profile | | Topology | standalone candidate; Cluster code seam; Sentinel runtime 미구현 | Sentinel/Cluster/k3s multi-node qualification |
| Failure | 모든 cache exception을 miss로 변환 | capability별 fail-open/closed/degraded/indeterminate | | Failure | capability별 typed degraded/unavailable/indeterminate와 bounded fault lane | 실제 topology event chain과 persistence/restart evidence |
| CI | fake unit test | real Redis, topology, concurrency, failure, compatibility matrix | | CI | strict registry matrix, real candidate lanes, sanitized artifact/reconciler | actual-used image attestation과 actual fault-event capture |
설계가 완료되었다는 뜻은 구현 계약과 단계가 결정되었다는 뜻이다. 현재 Redis runtime이 설계가 완료되었다는 뜻은 구현 계약과 단계가 결정되었다는 뜻이다. 현재 Redis runtime이
production-ready가 되었다는 뜻은 아니다. production-ready가 되었다는 뜻은 아니다.
@@ -145,7 +170,11 @@ production-ready가 되었다는 뜻은 아니다.
표의 링크 대상보다 예시 YAML이나 migration alias가 우선하지 않는다. 상충하는 두 설정이 표의 링크 대상보다 예시 YAML이나 migration alias가 우선하지 않는다. 상충하는 두 설정이
존재하면 임의 precedence를 선택하지 않고 startup을 실패시킨다. 존재하면 임의 precedence를 선택하지 않고 startup을 실패시킨다.
## 3. 증거 기반 현재 상태 ## 3. 설계 착수 당시 증거 기반 baseline
이 절 전체는 구현 전 repository를 조사한 2026-07-26 역사적 baseline이다. 아래의 “현재”는 그
조사 시점을 가리키며 2026-07-30 구현 상태를 설명하지 않는다. 최신 구현/readiness truth는 §0,
§1의 현재 열, checked-in `src/config/redis/readiness-cards.yaml`, Redis leaf README를 따른다.
### 3.1 실제 Redis client가 없다 ### 3.1 실제 Redis client가 없다
@@ -5904,8 +5933,8 @@ indexed repository는 Cluster/node-specific event와 orphan index cleanup을 별
최소 실제 topology: 최소 실제 topology:
- primary; - primary;
- replica; - replica 2개;
- independent Sentinel quorum. - 서로 다른 k3s node에 배치한 Sentinel 3개와 quorum 2.
test: test:
@@ -5921,6 +5950,260 @@ test:
단일 fake Sentinel endpoint로 HA를 증명하지 않는다. 단일 fake Sentinel endpoint로 HA를 증명하지 않는다.
#### 37.13.1 Sentinel discovery와 data runtime 분리
Sentinel discovery channel과 Redis data-node channel은 같은 Lettuce client/SSL context로
합치지 않는다. 각각 독립된 named material과 lifecycle을 갖는다.
| Channel | 책임 | 허용 material |
| --- | --- | --- |
| Sentinel discovery | master name 조회와 quorum 관측 | Sentinel ACL username/password reference, Sentinel CA/trust, discovery timeout |
| Redis data | capability command/program 실행 | data-node ACL username/password reference, data CA/trust, command/admission/drain timeout |
discovery는 다음 조건을 모두 만족할 때만 새 primary 후보를 반환한다.
- 구성된 Sentinel endpoint 최소 3개 중 2개 이상이 같은 master host/port를 보고한다;
- 응답한 Sentinel 수와 동의 수가 각각 bounded deadline 안에서 기록된다;
- master name이 exact configured name과 같다;
- 반환 endpoint가 loopback, wildcard, unspecified address가 아니고 allowlisted deployment
identity/member에 속한다;
- TLS hostname/SAN 검증을 통과한다;
- Sentinel credential 또는 trust를 data connection에, data material을 Sentinel connection에
재사용하지 않는다.
한 Sentinel의 응답, 최초 응답 또는 DNS 문자열 일치만으로 primary를 바꾸지 않는다. discovery
실패 detail에는 endpoint, username, secret reference/value, certificate subject를 남기지 않고
sanitized reason과 동의 수만 남긴다.
#### 37.13.2 bounded rediscovery와 runtime swap
정상 polling은 bounded single-flight로 실행하며, write/read command의 topology failure가
발생하면 같은 single-flight에 bounded immediate rediscovery를 요청한다. 새 primary가
qualification을 통과하면:
1. 새 data runtime을 생성한다;
2. version/program/semantic readiness를 검증한다;
3. 기존 `RedisRoleCommandRouter`에 한 번만 install한다;
4. 기존 runtime은 새 admission을 닫고 in-flight command를 bounded drain한다;
5. drain timeout 뒤에는 강제 close하되 완료되지 않은 mutation을 성공/미실행으로 추정하지 않는다.
failover 직전 또는 도중의 mutation은 자동 replay하지 않는다. transport가 실행 여부를 증명하지
못하면 capability가 `INDETERMINATE`를 반환하고, idempotency/session은 같은 operation token의
inspect/reconcile 또는 재인증 경로를 사용한다. read-only command도 semantic contract가 허용하는
경우에만 새 runtime에서 재시도한다.
`snapshot()`/readiness scrape는 정상 polling의 실행 엔진으로 사용하지 않는다. scrape나 command가
없는 동안에도 primary 변경을 발견해야 하므로, active Sentinel role이 하나 이상일 때만 registry가
다음 bounded poller를 소유한다.
- registry당 daemon worker 1개와 active Sentinel role당 fixed-delay task 1개만 만든다;
- 기본 polling period는 30초이고 typed setting은 5초 이상 5분 이하만 허용한다;
- scheduled poll과 command-failure trigger는 role별 같은 single-flight를 공유하며 한 role에
discovery/install 작업은 최대 1개만 실행하거나 대기한다;
- Standalone/Cluster만 선택되거나 Redis capability가 비활성이면 poller/thread/task를 0개 만든다;
- close는 새 trigger를 거절하고 scheduled task를 취소한 뒤 worker를 bounded shutdown하며,
close와 경합해 늦게 생성된 candidate는 install하지 않고 정확히 한 번 닫는다.
command failure signal은 route lease가 반환된 뒤 발행한다. connection/timeout/topology 계열의
`UNAVAILABLE`만 immediate rediscovery를 요청하고, overload, ACL denial, validation/size rejection은
요청하지 않는다. signal listener의 실패는 원래 command의 `NOT_APPLIED`/`INDETERMINATE` 판정을
절대 덮어쓰지 않는다.
정상 poll은 Sentinel discovery credential/CA만 사용해 endpoint를 조회한다. 현재 route와 같은
primary면 data credential/CA를 해석하거나 새 data connection을 열지 않는다. primary가 달라졌을
때만 이미 quorum-approved/allowlisted 된 exact endpoint로 data candidate를 열어 TOCTOU 성격의
이중 discovery를 피한다. route는 endpoint를 출력하지 않는 package-private identity와 monotonic
generation token을 가진다. candidate qualification 중 다른 rotation이 먼저 완료되면 stale
generation candidate를 닫고 install하지 않는다. 같은 identity도 candidate를 닫고 no-op 처리한다.
#### 37.13.3 replication 보장과 판정
Sentinel은 primary election을 제공하지만 asynchronous replication의 zero-data-loss를 보장하지
않는다. qualification 환경은 correctness role에 `min-replicas-to-write`와 bounded
`min-replicas-max-lag`를 설정하고, 중요한 mutation은 명시된 replica acknowledgement 정책을
사용한다. 이 설정도 strong consistency나 cross-store exactly-once 증거가 아니다.
failover 판정은 다음을 구분한다.
- 응답과 요구된 replica acknowledgement가 확인된 mutation: 새 primary에서 보존되어야 한다;
- response-only cut 또는 acknowledgement 결과를 확인할 수 없는 mutation:
`INDETERMINATE`, blind retry 금지;
- acknowledgement 전 명확한 connection/admission 실패: `NOT_APPLIED`가 wire evidence로
증명되는 경우에만 미실행으로 판정한다.
#### 37.13.4 Sentinel-first R2 qualification lab
이번 Phase 5의 첫 실행 slice는 기존 host k3s를 변경하지 않는 disposable Multipass lab이다.
```text
ca-redis-lab-server 2 CPU / 3 GiB / 12 GiB k3s server
ca-redis-lab-agent-1 2 CPU / 2.5 GiB / 12 GiB k3s agent
ca-redis-lab-agent-2 2 CPU / 2.5 GiB / 12 GiB k3s agent
pod CIDR 10.52.0.0/16
service CIDR 10.53.0.0/16
kube context ca-redis-lab
```
lab kubeconfig와 transient material/raw observation은 Gradle root의 ignored
`src/build/redis-lab` 아래에만 쓰며 사용자의 default kubeconfig에 merge하거나 덮어쓰지 않는다.
host 관측에는 default kubeconfig의 run-scoped copy와 시작 시점의 exact host context를
사용하지만, fingerprint/CIDR 관측이 끝난 즉시 성공/실패와 무관하게 copy를 제거한다. 모든
lab mutating command는 별도 lab kubeconfig와 `ca-redis-lab` context를 함께 요구한다.
VM 이름은 위 exact allowlist만 허용한다. launch 전에 exact name을 run-owned state에
`PENDING`으로 atomic 예약하고 성공 직후 `CREATED`로 승격한다. timeout, partial create,
state 승격 실패는 이 run이 예약한 exact name만 delete/purge한다. global `multipass purge`,
host `kubectl delete`, default-context write는 금지한다.
run-scoped rendered cloud-init은 secret이 아닌 exact `RUN_ID|VM_NAME` ownership marker를
instance에 기록한다. cleanup/down은 bounded marker read가 state owner와 name 일치를
증명할 때만 delete한다. launch timeout/error는 `RECONCILE` tombstone과 bounded late-create
poll로 처리한다. instance가 끝까지 없거나 marker가 unreadable/mismatch면 외부 same-name
instance를 추측해 삭제하지 않고 state를 유지한 채 fail-closed한다.
lifecycle 전체는 nonblocking exclusive lock과 run identity를 사용한다. direct `up`
`run` 모두 첫 launch 전에 emergency cleanup을 활성화하며 signal/concurrent invocation이
다른 run의 state 또는 VM을 채택·삭제하지 못한다. `run -- <command>`에는 lifecycle lock file
descriptor를 상속하지 않는다. K3s는 mutable installer를 pipe로 실행하지 않고 exact release
URL/SHA-256을 repository에 pin한다. host download와 각 VM transfer 뒤 checksum/version을
다시 확인한 후에만 start한다.
기본 bounded external child도 lifecycle lock descriptor를 닫으며 lock acquisition만
명시적인 keep-lock 경로를 사용한다.
`run`의 inner `up` 성공과 user command 시작 사이에도 cleanup-required flag는 연속 유지되며,
signal handler가 ownership을 0으로 보는 handoff gap을 허용하지 않는다.
lab kubeconfig renderer는 one-cluster/context/user schema의 모든 identity-bearing key를
generic count하며 duplicate/extra server, context cluster/user, item/name,
current-context를 last-key-wins로 남기지 않고 fail-closed한다.
#### 37.13.4.1 lab lifecycle 완료 경계와 strict kubeconfig renderer
`Task 11.1A`는 하나의 리뷰 단위로 너무 많은 책임을 가졌으므로 다음 두 하위 작업으로 분리한다.
- `Task 11.1A-1`: VM 이름/소유권 marker, `PENDING|CREATED|RECONCILE` state, lock FD,
signal/handoff cleanup, host fingerprint와 bounded external command를 소유한다.
- `Task 11.1A-2`: pinned K3s admin kubeconfig의 strict validation과 lab 전용 rename/render만
소유한다.
`11.1A-1` 코드는 `11.1A-2` 동안 동결한다. `11.1A-2`가 독립 테스트와 독립 리뷰를 통과하기
전에는 부모 `11.1A`를 완료로 표시하지 않으며 VM 생성도 허용하지 않는다.
`11.1A-2`는 범용 YAML parser가 아니다. 입력은 pinned K3s가 생성하는 admin kubeconfig의
canonical block-style 문서 하나로 제한한다. 별도 tracked
`infra/redis-lab/lib/render-kubeconfig.awk`가 line/indentation/state allowlist를 적용하며,
identity-bearing key를 찾는 denylist나 발견된 mutation별 정규식 패치를 사용하지 않는다.
허용 grammar는 다음을 모두 만족해야 한다.
- top-level `apiVersion`, `clusters`, `contexts`, `current-context`, `kind`, `preferences`,
`users`는 canonical 순서와 exact spelling/indentation으로 한 번만 존재한다;
- cluster/context/user list는 각각 한 항목만 가지며 identity는 모두 exact `default`다;
- cluster는 exact loopback `server: https://127.0.0.1:6443`와 하나의
`certificate-authority-data` scalar만 가진다;
- context는 exact `cluster: default`, `user: default`와 optional single `namespace` scalar만
가진다;
- user는 하나의 `client-certificate-data``client-key-data` scalar만 가진다;
- `preferences: {}`만 유일한 flow collection 예외다. 그 밖의 `{}`, `[]`, quoted/tagged/
explicit key, anchor, alias, merge key, tab, CRLF, YAML document marker, unknown key,
duplicate/reordered identity, trailing content는 fail-closed한다;
- source `server`, cluster/context/user name과 current-context만 변환한다. CA/client material,
namespace와 그 밖의 허용 scalar는 byte-preserving pass-through다;
- renderer source 자체와 destination의 canonical parent/symlink/permission 계약을 lifecycle
static validation에 포함한다. validation 또는 render 실패 시 destination을 제거하고
constant sanitized failure만 출력한다.
정상 fixture는 pinned K3s admin kubeconfig의 certificate-data shape를 사용한다. negative
mutation은 duplicate/extra identity뿐 아니라 canonical item 아래의 sibling
`cluster : {...}`, `context : {...}`, whitespace-before-colon, flow collection, quoted/tagged/
anchor/alias/merge, unknown/reordered/missing key를 포함한다. 모든 실패는 lab `kubectl` 전에
발생하고 현재 invocation이 marker로 증명한 VM만 cleanup하며 prior
`CREATED|RECONCILE` state는 byte-for-byte 보존한다.
tracked `infra/redis-lab`에는 lifecycle script, cloud-init template, Redis/Sentinel config
template, Kubernetes manifest와 secret 없는 contract test만 둔다. 실행 시 생성하는 k3s token,
ACL password, data/Sentinel/untrusted CA와 private key, rendered Secret/config, raw observation은
`umask 077`인 transient directory에만 둔다. `redis-cli --pass`, tracked PEM/Secret data,
`hostPath`/`hostNetwork`/privileged/NodePort/LoadBalancer는 사용하지 않는다.
host isolation은 preflight/postflight의 canonical projection을 비교한다. default kubeconfig
digest, current context/API, sorted node/providerID/podCIDR, controller replica, Service NodePort,
host interface/route CIDR와 Multipass inventory가 대상이다. host service CIDR은 현재 할당된
ClusterIP만 보고 추측하지 않고, 명시적으로 검증한 input 또는 신뢰할 수 있는 host 설정에서
읽는다. 외부 명령과 exact 3-node Ready 대기는 bounded다. 불일치 시 qualification을
실패시키되 script가 host 상태를 추측해 되돌리려고 mutate하지 않는다.
workload는 Redis primary 1 + replica 2, Sentinel 3/quorum 2를 서로 다른 node에 배치한다.
data와 Sentinel은 stable ordinal/headless DNS가 필요한 별도 StatefulSet이며
`kubernetes.io/hostname` required anti-affinity와 `maxSkew=1/DoNotSchedule` topology spread를
사용하고 `podManagementPolicy: Parallel`을 명시한다. data는 PVC와 AOF
`appendfsync everysec`를 사용한다. Sentinel config는 discovery/failover 시 rewrite되므로
bootstrap 원본을 pod별 writable PVC config로 최초 1회 atomic init-copy하되 restart 때 이미
존재하는 rewritten config를 덮어쓰지 않는다. 비어 있거나 손상된 기존 config도 자동으로
덮지 않고 startup을 실패시켜 증거를 보존한다.
data/Sentinel plaintext port는 0이며 TLS port만 연다. `tls-replication yes`, hostname
resolution/announcement와 stable DNS SAN을 사용한다. data plane과 Sentinel plane의 CA/leaf
material은 분리하며 peer 연결에 필요한 root만 explicit trust bundle에 포함한다. ACL은
application data, replica, Sentinel-to-data, Sentinel peer, application Sentinel discovery
identity로 나눈다. Redis data ACL과 Sentinel ACL은 별도 template/projection이며 plane
identity를 서로 노출하지 않는다. default user는 off이며 application/data/discovery
identity에는 `+@all`, `allkeys`, `allchannels`를 주지 않는다. replica는
`+psync +replconf +ping`, Sentinel-to-data identity는 Sentinel control에 필요한 최소
command/channel set만 가진다.
exec probe를 사용하고 default-deny NetworkPolicy 뒤 data 6379, Sentinel 26379, kube-dns,
exact qualification/application pod selector만 허용한다. data/Sentinel PDB는 각각
`minAvailable: 2`이며 non-root, read-only root filesystem, privilege-escalation false,
capability drop ALL, seccomp RuntimeDefault, requests/limits를 요구한다. `hostPath`,
host namespaces, privileged, NodePort/LoadBalancer와 tracked Secret/PEM은 금지한다.
정적 lifecycle contract와 manifest/security contract는 VM 없이 blocking check에서 검증하고,
한 필드씩 제거/변조하는 mutation-negative fixture로 실제 방어력을 확인한다. 이 정적 통과는
TLS handshake, ACL authorization, CNI enforcement, scheduling/failover의 실행 증거가 아니다.
shell contract는 별도 fixture repository만 사용하며 actual `src/build/redis-lab` state를
byte-for-byte 보존한다. fake PATH는 explicit safe wrapper 외 모든 명령을 fail-closed한다.
live lab에서는 TLS/ACL negative test, `SENTINEL CKQUORUM`, writable config rewrite/restart,
exact 3 Ready placement, PDB/NetworkPolicy enforcement와 image ID/digest를 별도로 검증한다.
Redis image는 `src/gradle/redis-test-images.properties``redis.minimum.image` exact
tag+digest를 사용한다.
ordinal bootstrap은 최초 `redis-data-0` primary와 두 replica만 정적으로 증명한다.
failover 동안 죽어 있던 old primary가 재합류할 때 readiness가 stale direct write를 허용하지
않고 새 primary의 replica로 수렴하는지는 live gate다. PDB 선언은 voluntary eviction
제약일 뿐 node/AZ failure 증거가 아니다.
k3s control-plane HA, physical host/AZ failure, Redis Cluster는 이 lab의 증거가 아니다.
hosted GitHub Actions에서는 Multipass를 설치하거나 실행하지 않는다. 실제 lab qualification은
trusted dedicated runner 또는 local explicit execution에서만 허용한다. 외부 PR 코드를
self-hosted lab에서 실행하지 않는다.
초기 test budget은 운영 SLA가 아니라 bounded regression limit이다.
- Sentinel election: 60초 이내;
- client rediscovery와 runtime swap: election 뒤 추가 30초 이내;
- required semantic readiness 복구: fault injection 뒤 총 90초 이내.
실제 측정값을 evidence timeline에 기록하며 limit만 기록한 문서는 증거가 아니다.
#### 37.13.5 Sentinel-first capability acceptance
이 slice는 correctness-sensitive cross-pod state를 우선 검증한다.
- edge rate limit: failover 전 quota state가 조용히 reset되지 않고 evaluation replay가 일관된다;
- request-replay idempotency: claim/start/renew/complete와 terminal replay가 owner-safe하며
불확실 mutation은 중복 실행하지 않는다;
- Redis session: create/read/touch/rotate/revoke가 서로 다른 application pod에서 보이고,
failover 뒤 confirmed state가 유지되며 stale session이 부활하지 않는다;
- cache refresh soft lease와 optional cache는 공통 runtime 회귀를 확인하되 이 slice만으로
Cluster scaling 또는 distributed L1 invalidation R2를 주장하지 않는다.
fault 순서는 baseline qualification 뒤 current primary pod를 kill하고 readiness unavailable,
Sentinel quorum election, client rediscovery, runtime swap/drain, semantic readiness recovery를
실제 timestamp로 수집한다. old primary는 replica로 재합류해야 하고, recovery 뒤 모든 actor가
같은 runtime generation을 관측해야 한다.
evidence bundle은 실제 실행 image digest/image ID, config/program digest, fault/election/recovery
timeline, capability별 outcome/certainty, sanitized Kubernetes/Sentinel observation, lab teardown
결과를 포함한다. manifest의 `NOT_CAPTURED`를 문자열로 바꾸는 것만으로 증거를 만들 수 없다.
### 37.14 Cluster topology ### 37.14 Cluster topology
최소 multi-primary Cluster와 replica에서: 최소 multi-primary Cluster와 replica에서:
@@ -6115,20 +6398,9 @@ canonical card ID와 Gradle task mapping:
registry key, capability descriptor ID, `card-<id>` tag, evidence artifact의 card ID는 이 표와 byte-for-byte registry key, capability descriptor ID, `card-<id>` tag, evidence artifact의 card ID는 이 표와 byte-for-byte
같아야 한다. short alias를 허용하지 않는다. 같아야 한다. short alias를 허용하지 않는다.
```yaml 현재 card 상태와 topology/evidence는 이 문서에 복제하지 않으며
cards: `src/config/redis/readiness-cards.yaml`만을 따른다. 현재 `selected` card는 없으며,
redis-cache: `implemented-candidate`는 release selection 또는 R2 qualification을 뜻하지 않는다.
state: selected # selected | implemented-candidate | not-implemented
selected-topology: sentinel # standalone | sentinel | cluster
required-evidence:
- standalone
- security
- fault
- compatibility
- selected-topology
redis-session:
state: not-implemented
```
`redis<Card>Readiness` task는 이 registry의 해당 card tag와 required evidence tag의 교집합을 `redis<Card>Readiness` task는 이 registry의 해당 card tag와 required evidence tag의 교집합을
실행하고, category마다 test count > 0, 성공 artifact, image/program/config digest를 요구한다. 실행하고, category마다 test count > 0, 성공 artifact, image/program/config digest를 요구한다.
@@ -6224,10 +6496,14 @@ nightly `redis-all-candidates`는 `redisAllImplementedCandidates`를 실행한
품질 신호/승격 blocker지만 현재 selected card의 이미 존재하는 release evidence를 다른 card 품질 신호/승격 blocker지만 현재 selected card의 이미 존재하는 release evidence를 다른 card
미구현 때문에 자동 취소하지 않는다. 미구현 때문에 자동 취소하지 않는다.
각 job은 JUnit XML/HTML, container logs, sanitized topology/fault timeline, 각 job은 `build/redis-evidence` 아래에서 allowlist schema로 다시 생성한 bounded manifest,
`program-set.json`/digest, effective capability card, image digest attestation을 artifact로 올린다. capability card, sanitized test summary만 artifact로 올린다. Gradle의 raw JUnit XML/HTML,
secret, raw Redis key/value, session/idempotency token은 artifact에 포함하지 않는다. PR artifact `system-out`/`system-err`, stack trace, container log/inspect, TLS/ACL fixture material은 업로드하지
retention은 짧게, release evidence는 조직의 audit retention 정책에 맞춘다. 않는다. 실제 사용 image attestation과 실제 topology/fault event chain을 수집하지 못한 현재
artifact는 각각 `NOT_CAPTURED``releaseQualification=NOT_CLAIMED`를 기록하며, reconciler는
이 상태의 future `selected` 승격을 실패시킨다. secret/reference value, raw endpoint/key/value,
session/idempotency/lease token은 artifact에 포함하지 않는다. Candidate artifact retention은
짧게, 실제 release evidence는 조직의 audit retention 정책에 맞춘다.
### 37.24 no silent skip ### 37.24 no silent skip
@@ -6526,6 +6802,36 @@ Acceptance:
- no silent skip; - no silent skip;
- program/ACL/schema conformance. - program/ACL/schema conformance.
#### Phase 5A — Sentinel-first R2 qualification slice
Phase 5 전체를 한 번에 구현하지 않는다. 먼저 §37.13의 disposable 3-node k3s Sentinel 환경에서
다음 순서로 진행한다.
1. lab lifecycle/preflight/host-isolation contract를 테스트 우선으로 고정한다;
2. Sentinel discovery와 Redis data runtime을 별도 auth/trust/lifecycle로 구현한다;
3. quorum-consistent discovery, bounded rediscovery, qualified runtime swap와 bounded drain을
구현한다;
4. security positive/negative test 후 rate limit, idempotency, session의 multi-pod 정상 경로를
실행한다;
5. primary kill과 response-loss fault를 주입하고 capability invariant와 `INDETERMINATE`
semantics를 검증한다;
6. image/fault timeline을 실제 관측에서 생성하고 sanitizer/reconciler를 통과시킨다;
7. focused/full Gradle verification과 독립 review를 마친 뒤 이 slice에서 멈춘다.
이번 slice에 포함하지 않는 항목:
- Redis Cluster와 Cluster cache scaling;
- fenced coordination;
- R3 capacity soak/long chaos/reshard;
- k3s control-plane HA, physical host/AZ failure;
- full credential/certificate rotation drill;
- optional cache의 Sentinel release promotion.
이번 slice의 agent-side 종료 상태는 `R2-ready candidate`다. repository가 human-only commit
policy를 사용하므로 clean committed source와 실제 remote GitHub Actions evidence는 사람이
수행하는 최종 promotion gate다. 이 두 증거가 없으면 readiness card를 `selected`로 바꾸거나
R2라고 표시하지 않는다.
### Phase 6 — R3와 split review ### Phase 6 — R3와 split review
- actual Cluster reshard/failover; - actual Cluster reshard/failover;
@@ -6573,6 +6879,28 @@ Acceptance:
- runbook/capability card; - runbook/capability card;
- LLM Wiki capture. - LLM Wiki capture.
### 40.2 Sentinel-first slice 종료 게이트
§37.13과 Phase 5A의 작업은 아래가 모두 충족된 경우에만 `R2-ready candidate`로 종료한다.
- exact VM inventory와 dedicated kubeconfig로 lab create/verify/destroy가 반복 가능하다;
- host k3s context, node, workload와 default kubeconfig의 전/후 fingerprint가 같다;
- Sentinel discovery와 Redis data auth/trust가 분리되고 negative security test가 통과한다;
- primary kill 뒤 quorum election, qualified runtime swap, bounded drain과 semantic readiness
recovery의 실제 timeline이 있다;
- rate limit, idempotency, session을 서로 다른 pod에서 검증하고 failover 뒤 invariant가
유지된다;
- confirmed acknowledgement와 `INDETERMINATE`를 구분하며 blind mutation replay가 없다;
- actual image/config/program digest와 sanitized evidence가 reconciler를 통과한다;
- focused test, Redis readiness 관련 task, repository `test`/`check`, architecture/env/public-path
gate와 독립 review가 통과한다;
- exact allowlist VM teardown과 lab resource 정리 결과가 기록된다.
위 조건은 clean committed source와 실제 remote CI를 대신하지 않는다. 두 최종 promotion
증거가 없으면 card 상태는 `implemented-candidate`, `releaseQualification=NOT_CLAIMED`
유지한다. 종료 뒤 Redis Cluster/R3/fenced coordination 또는 fileserver/HTTP client로 자동으로
넘어가지 않고 다음 우선순위를 다시 결정한다.
R3는 추가로: R3는 추가로:
- failover/partition; - failover/partition;
@@ -1,7 +1,7 @@
# HTTP Client Production Capability Deep Design # HTTP Client Production Capability Deep Design
- 작성일: 2026-07-27 - 작성일: 2026-07-27
- 상태: 상세 설계 완료, Phase 0/1 기반legacy deadline R1 구현, R2 미구현 - 상태: 상세 설계 완료, Phase 0/1 기반·legacy deadline R1·canonical zero-binding 구현, R2 미구현
- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture - 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture
- 대상 leaf: `adapter-outbound-httpclient` - 대상 leaf: `adapter-outbound-httpclient`
- 구현 추적: typed operation/target foundation, legacy JDK 안전 결함과 active logical deadline - 구현 추적: typed operation/target foundation, legacy JDK 안전 결함과 active logical deadline
@@ -28,11 +28,16 @@
- client별 bounded live-worker admission, non-cooperative worker의 slot retention, shutdown 시 - client별 bounded live-worker admission, non-cooperative worker의 slot retention, shutdown 시
active task cancellation과 신규 admission 차단; active task cancellation과 신규 admission 차단;
- worker MDC 복사/정리와 retry ThreadLocal lifecycle 정렬. - worker MDC 복사/정리와 retry ThreadLocal lifecycle 정렬.
- strict canonical expected-state/binding/provider map binder와 exact provider/destination/catalog
resolver;
- `DISABLED_VERIFIED` descriptor와 zero-binding HTTP runtime resource 0 composition;
- `httpclient-static-buffered=NOT_IMPLEMENTED` fail-closed ACTIVE admission;
- legacy settings/configuration의 global Spring scan 분리와 explicit migration binder.
아직 구현되지 않은 범위: 아직 구현되지 않은 범위:
- application feature-specific production port와 실제 upstream anti-corruption adapter; - application feature-specific production port와 실제 upstream anti-corruption adapter;
- canonical binding/expected-state/full profile tuple/card registry와 zero-binding resource 0 계약; - full compatibility profile tuple/scenario registry와 release-eligible readiness evidence;
- Apache HC5 pool/acquire/lifetime/idle provider; - Apache HC5 pool/acquire/lifetime/idle provider;
- Apache engine phase별 deadline 전달, wire hard cancellation과 connection quarantine; - Apache engine phase별 deadline 전달, wire hard cancellation과 connection quarantine;
- DNS/address/SSRF/TLS/mTLS/proxy/auth/secret lifecycle; - DNS/address/SSRF/TLS/mTLS/proxy/auth/secret lifecycle;
@@ -42,7 +47,8 @@
따라서 현재 `OutboundHttpClient`는 migration용 JDK R1 이하 facade이며 HTTP capability R2가 아니다. 따라서 현재 `OutboundHttpClient`는 migration용 JDK R1 이하 facade이며 HTTP capability R2가 아니다.
legacy 실행 경로는 active logical deadline을 사용하지만 operation catalog와 engine phase legacy 실행 경로는 active logical deadline을 사용하지만 operation catalog와 engine phase
deadline을 아직 사용하지 않는다. 이 단면만으로 hard cancellation이나 R2를 주장하지 않는다. deadline을 아직 사용하지 않는다. Canonical ACTIVE도 현재 `NOT_IMPLEMENTED` card에서 실패한다.
이 단면만으로 hard cancellation이나 R2를 주장하지 않는다.
## 1. 설계 판정 ## 1. 설계 판정
@@ -147,11 +153,12 @@ production capability는 아니다.
dependency별 configuration에서 `baseline(...)`을 직접 호출하도록 안내한다. repository 전체에서 dependency별 configuration에서 `baseline(...)`을 직접 호출하도록 안내한다. repository 전체에서
이를 호출하는 production consumer는 없다. 이를 호출하는 production consumer는 없다.
다만 “binding 0개”가 HTTP 관련 bean 0개라는 뜻은 아니다. Component scan이 이 configuration을 초기 조사 시점에는 component scan이 `OutboundHttpSettings`, shutdown guard,
읽으면 `OutboundHttpSettings`, shutdown guard, `RestClient`/builder 차단 BeanPostProcessor, `RestClient`/builder 차단 BeanPostProcessor, error mapper, logger와 retry policy를 생성해
error mapper, logger와 retry policy 같은 global infrastructure bean은 생성된다. Named “binding 0개”와 “HTTP resource 0개”가 일치하지 않았다. Phase 1 구현에서 이 결함은 폐쇄됐다.
client/semantic-port binding은 없는데 required global timeout 설정과 전역 부작용은 존재하는 현재 settings와 두 legacy configuration은 global scan 대상이 아니며 canonical composition은
비대칭 상태다. immutable configuration, registry, resolver와 sanitized `DISABLED_VERIFIED` descriptor만 만든다.
기본 `application.yml``application-test.yml`도 legacy `app.outbound.http.*`를 선언하지 않는다.
다만 sample에는 이미 다음 seam이 있다. 다만 sample에는 이미 다음 seam이 있다.
@@ -4893,6 +4900,11 @@ inbound/use-case budget
## 33. Configuration design ## 33. Configuration design
2026-07-28 구현 단면은 canonical expected-state/binding/provider map의 strict binding, exact
provider/destination/code-owned catalog resolution과 `httpclient-static-buffered` card derivation까지
포함한다. 아래 full provider tuple의 pool/security/TLS/auth 필드는 아직 bind/runtime model로
구현되지 않았다.
### 33.1 Canonical activation shape ### 33.1 Canonical activation shape
상위 capability platform과 같은 canonical prefix를 사용한다. 상위 capability platform과 같은 canonical prefix를 사용한다.
@@ -5229,7 +5241,15 @@ Base URI, proxy endpoint, SSL bundle/secret reference 변경은 운영 영향이
### 33.7 Legacy migration ### 33.7 Legacy migration
현재 `app.outbound.http.*`는 migration-only alias다. `app.outbound.http.*` canonical application configuration에 포함되지 않는 migration-only
입력이다.
현재 구현은 global `@ConfigurationPropertiesScan`을 제거하고
`OutboundHttpSettings.bindLegacy(Binder)`/직접 생성자만 남겼다. Canonical composition은
expected state가 DISABLED여도 legacy property가 하나라도 보이면 silent no-op 대신
fail-closed한다. Legacy fork는 canonical composition 밖에서 migration binder와 configuration을
명시적으로 import해야 한다. 아래 deprecation warning, one-destination conversion,
release-window removal은 후속 migration 단계다.
1. legacy만 있으면 deprecation warning과 함께 immutable legacy settings로 변환; 1. legacy만 있으면 deprecation warning과 함께 immutable legacy settings로 변환;
2. canonical과 legacy가 동시에 있으면 값이 같아도 startup failure; 2. canonical과 legacy가 동시에 있으면 값이 같아도 startup failure;
@@ -5257,6 +5277,10 @@ Application은 adapter type, `RestClient`, Apache type을 알지 못한다.
### 34.2 Zero-binding contract ### 34.2 Zero-binding contract
이 절의 resource 0 계약은 `HttpClientCompositionConfigTest`
`OptionalAdapterBeanGatingTest`로 구현됐다. 기본 composition은 inert registry/resolver/descriptor
외에 HTTP runtime bean을 만들지 않으며 `DISABLED_VERIFIED`만 게시한다.
Binding이 없으면 다음이 모두 0개여야 한다. Binding이 없으면 다음이 모두 0개여야 한다.
- engine client와 connection manager; - engine client와 connection manager;
@@ -1,7 +1,7 @@
# Fileserver R2 Control Plane and Provider Selection Design # Fileserver R2 Control Plane and Provider Selection Design
- Date: 2026-07-28 - Date: 2026-07-28
- Status: 승인된 설계, 구현 전 - Status: 구현·전체 repository gate·독립 spec/quality review 완료
- Scope: provider-neutral R2 control plane, explicit destination/provider selection, first - Scope: provider-neutral R2 control plane, explicit destination/provider selection, first
`local-persistent` qualification provider `local-persistent` qualification provider
- Parent: - Parent:
@@ -106,7 +106,9 @@ FILE_AND_DIRECTORY_SYNC
fsr1.<route-token>.<file-id>.<check-digits> fsr1.<route-token>.<file-id>.<check-digits>
``` ```
- `route-token`: startup에서 생성된 bounded destination route allowlist 값; - `route-token`: destination binding의 canonical policy digest에서 재시작 안정적으로 파생한
bounded route allowlist 값. 형식은 `r` + digest의 첫 31 lowercase hex이며 startup에서 token
collision을 거부한다;
- `file-id`: CSPRNG 128-bit 이상; - `file-id`: CSPRNG 128-bit 이상;
- `check-digits`: accidental truncation/corruption 검출; - `check-digits`: accidental truncation/corruption 검출;
- provider locator, operation ID, tenant/user ID, host/path는 포함하지 않는다. - provider locator, operation ID, tenant/user ID, host/path는 포함하지 않는다.
@@ -146,6 +148,9 @@ app:
- `enabled=true`이면 destination과 provider가 각각 하나 이상 필요하다. - `enabled=true`이면 destination과 provider가 각각 하나 이상 필요하다.
- 모든 destination은 존재하는 provider 하나를 참조한다. - 모든 destination은 존재하는 provider 하나를 참조한다.
- provider ID별로 provider/control/payload runtime을 정확히 하나만 만들며 같은 provider를
참조하는 destination은 그 인스턴스를 공유한다. 서로 다른 provider ID가 같은 normalized
root를 가리키면 동일 control namespace의 이중 소유가 되므로 startup에서 거부한다.
- request destination에 binding이 없으면 producer 호출 전에 실패한다. - request destination에 binding이 없으면 producer 호출 전에 실패한다.
- provider type의 기본값은 없다. - provider type의 기본값은 없다.
- `local-persistent` root는 absolute, existing, pre-provisioned directory여야 한다. - `local-persistent` root는 absolute, existing, pre-provisioned directory여야 한다.
@@ -156,12 +161,34 @@ app:
- container ephemeral 경로를 위한 `local-dev`는 별도 후속 profile이다. production 설정과 - container ephemeral 경로를 위한 `local-dev`는 별도 후속 profile이다. production 설정과
같은 guarantee를 공유하지 않는다. 같은 guarantee를 공유하지 않는다.
- 기존 `ca-skeleton.fileserver.*`는 R1/legacy compatibility selector로만 남는다. 새 R2 설정과 - 기존 `ca-skeleton.fileserver.*`는 R1/legacy compatibility selector로만 남는다. 새 R2 설정과
동시에 활성화되면 startup을 실패시킨다. 암묵 migration이나 precedence를 두지 않는다. 동시에 활성화되면 어느 쪽 filesystem 초기화보다 먼저 startup을 실패시킨다. 양쪽 bean
factory가 같은 ambiguity validator를 호출해 Spring bean 생성 순서에 의존하지 않으며, 암묵
migration이나 conditional precedence를 두지 않는다.
- R2 settings는 unknown field를 거부해 provider/destination 키 오타를 silent fallback으로
취급하지 않는다.
## 7. Startup capability compilation ## 7. Startup capability compilation
application traffic을 받기 전에 destination별 effective descriptor를 한 번 compile한다. application traffic을 받기 전에 destination별 effective descriptor를 한 번 compile한다.
Descriptor compilation과 first reservation은 새 설정 키 없이 같은 canonical digest helper를
사용한다.
- startup descriptor는 destination ID, provider ID, limits, required guarantees,
format/encoder revision을 length-prefixed canonical encoding으로 직렬화한
`effectivePolicyDigest`를 freeze한다;
- ordered schema ID/version/column contract를 같은 canonical encoding 규칙으로 계산하는
request별 `schemaDigest`는 first reservation에서 계산한다;
- startup descriptor는 format/encoder revision과 canonical options의 `formatPolicyDigest`
freeze한다;
- `r` + `effectivePolicyDigest`의 첫 31 lowercase hex로 만든 32-character deterministic route
token.
문자열 단순 연결이나 JVM/JSON map iteration order에 digest를 의존시키지 않는다. 같은 startup
allowlist 안에서 route token이 충돌하면 더 긴 prefix로 임의 복구하지 않고 startup을 실패시킨다.
기존 operation은 journal에 freeze된 revision/digest/token으로만 복구하며 현재 설정으로 조용히
재해석하지 않는다.
`local-persistent`는 다음을 모두 검증한다. `local-persistent`는 다음을 모두 검증한다.
1. root와 모든 ancestor가 symbolic link가 아니다. 1. root와 모든 ancestor가 symbolic link가 아니다.
@@ -206,7 +233,9 @@ data/<prefix>/<generated-file-name>
``` ```
모든 locator는 validated single segment 또는 adapter가 생성한 bounded relative segment다. 모든 locator는 validated single segment 또는 adapter가 생성한 bounded relative segment다.
Caller path를 받지 않는다. Caller path를 받지 않는다. Manifest/reference의 `internalLocator`는 generated filename 한
segment만 저장하고, data shard는 `fileId`의 첫 두 hex에서 파생한다. 따라서 실제 lookup은
`data/<file-id-prefix>/<internalLocator>`이며 control record에 slash를 저장하지 않는다.
### 8.1 Operation journal v2 ### 8.1 Operation journal v2
@@ -267,6 +296,21 @@ relative locator로 direct lookup한다. Directory scan은 receipt restoration
순서로 갱신한다. 낮은 revision, fingerprint mismatch, newer schema는 자동 덮어쓰지 않는다. 순서로 갱신한다. 낮은 revision, fingerprint mismatch, newer schema는 자동 덮어쓰지 않는다.
Operation schema v2는 별도 `formatPolicyDigest` snapshot을 저장하지 않으므로 recovery는 저장된
`effectivePolicyRevision``effectivePolicyDigest`가 현재 compiled destination과 정확히 같을
때만 현재 format-policy digest를 사용한다. Encoder/policy 변경으로 digest가 달라지면 과거
format을 추정하지 않고 indeterminate로 중단한다. 여러 format revision에 대한 forward
recovery는 non-secret policy snapshot을 포함하는 후속 operation schema에서만 지원한다.
Operation direct lookup은 같은 secure relative read에서 schema를 typed dispatch한다. Schema v2는
현재 R2 record로만 decode/write하고, schema v1은 strict UTF-8 decode 후 canonical v1 re-encode
byte equality를 만족하는 terminal compatibility record만 read-only로 반환한다. Unknown/newer
schema, malformed UTF-8, non-canonical v1은 absent로 취급하지 않는다.
Crash qualification을 위해 control-plane fault context는 package-private로 record kind,
record identity, 해당하는 경우 operation state/revision, force boundary를 함께 전달한다.
Production 기본 callback은 no-op이며 runtime 설정이나 public bean으로 노출하지 않는다.
## 9. Publication ordering ## 9. Publication ordering
```text ```text
@@ -290,6 +334,18 @@ J-PUBLISHED
- terminal journal force 전에는 receipt를 반환하지 않는다. - terminal journal force 전에는 receipt를 반환하지 않는다.
- target collision, digest mismatch 또는 root identity change는 자동 overwrite하지 않는다. - target collision, digest mismatch 또는 root identity change는 자동 overwrite하지 않는다.
- final data가 있어도 manifest/reference가 없으면 아직 terminal success가 아니다. - final data가 있어도 manifest/reference가 없으면 아직 terminal success가 아니다.
- staging/data shard 생성, stage force, stable no-follow read/digest, exact delete는
package-private `PayloadOperations`를 통해 `SecureDirectoryStream` 상대 연산으로 수행한다.
Portable relative primitive가 없는 hard-link와 directory force만 private-owner boundary 안에서
root/directory/file identity pre/post 검증으로 감싼다.
- hard-link 뒤 journal 갱신 전에 중단된 `SEALED + matching data` 복구는 기존 data shard를 다시
identity 검증하고 directory force한 뒤에만 `DATA_PUBLISHED`로 전이한다. 이미 존재하는 data를
overwrite-capable publication 경로에 다시 넣지 않는다.
- `WRITING` 저장 뒤 producer 또는 stage/write가 실패하면 partial stage를 exact cleanup하고
unsealed `QUARANTINED` evidence를 남긴다. 원래 producer exception은 보존하고 cleanup/control
failure는 suppressed로 연결한다. Retry 진입 시 기존 `WRITING` 또는 unsealed
`QUARANTINED`가 보이면 producer를 다시 호출하지 않고 indeterminate/quarantine으로
fail-closed한다.
## 10. Deterministic recovery ## 10. Deterministic recovery
@@ -303,7 +359,9 @@ Recovery는 operation ID direct lookup으로 실행하며 startup full scan에
| DATA_PUBLISHED + matching data | manifest publication 재개 | | DATA_PUBLISHED + matching data | manifest publication 재개 |
| MANIFEST_PUBLISHED + matching manifest/data | reference publication 재개 | | MANIFEST_PUBLISHED + matching manifest/data | reference publication 재개 |
| REFERENCE_PUBLISHED + all matching | terminal journal 완성 | | REFERENCE_PUBLISHED + all matching | terminal journal 완성 |
| data digest mismatch | `QUARANTINED`, integrity failure | | non-terminal data/manifest/reference digest mismatch | `QUARANTINED`, integrity failure |
| `PUBLISHED` artifact/metadata/receipt mismatch | terminal journal과 artifacts를 불변 보존하고 typed integrity/indeterminate |
| required manifest/reference/data 누락 | 성공 복원 금지, fail-closed indeterminate/quarantine |
| marker/manifest/reference schema newer | 보존 후 fail-fast/quarantine | | marker/manifest/reference schema newer | 보존 후 fail-fast/quarantine |
| fingerprint conflict | typed conflict, 기존 artifact 보존 | | fingerprint conflict | typed conflict, 기존 artifact 보존 |
| root/mount identity change | indeterminate, write/recovery 중단 | | root/mount identity change | indeterminate, write/recovery 중단 |
@@ -317,12 +375,35 @@ matching data + private manifest + reference
> in-memory state > in-memory state
``` ```
모순이 있으면 임의 성공이나 삭제 대신 quarantine evidence를 기록한다. 모순이 있으면 임의 성공이나 삭제를 하지 않는다. Non-terminal operation은 기존 operation
journal을 `QUARANTINED`로 전이할 수 있다. 이미 `PUBLISHED`인 operation은 terminal
receipt snapshot을 지우거나 journal을 덮지 않고 관련 data/manifest/reference도 보존한 채 typed
integrity/indeterminate로 실패한다. 별도 immutable quarantine incident record는 후속 설계 전까지
가정하지 않는다.
Recovery verifier는 operation, incoming request, data, manifest, reference, receipt snapshot의
identity/digest/locator/count/time/guarantee를 모두 교차검증한다. Terminal receipt는 verified
manifest/reference에서 재구성한 expected receipt와 전체 equality가 확인될 때만 반환한다.
Operation record의 일부 필드만 맞거나 durability/publication guarantee, file version,
format/media/charset가 다르면 terminal success가 아니다. Crash 뒤 먼저 발견한 immutable
manifest/reference의 verified `publishedAt`은 새 clock 값으로 덮지 않고 recovery context로
재사용한다. 새 attempt에만 현재 configured maximum을 적용하고, sealed recovery artifact는
operation에 freeze된 exact byte size로 bounded inspection한다. Stage와 data가 함께 있으면
digest equality만이 아니라 stable file key가 같은 hard-link인지 확인한 뒤에만 stage를
exact-delete한다.
## 11. Compatibility ## 11. Compatibility
- R1 journal schema v1은 읽을 수 있어야 한다. - R1 compatibility는 별도 미설정 root나 동시에 활성화된 legacy bean이 아니다. Operator가 기존
R1 root를 owner/mode/FileStore/sentinel 등 R2 attestation 조건에 맞춰 명시적으로
pre-provision한 뒤, 그 root를 R2 destination으로 전환하는 in-place read-only migration이다.
- R1과 R2 operation journal은 같은 hashed path를 사용하므로 secure relative typed schema
dispatch로 schema v1을 읽고 schema v2만 쓴다.
- R1 journal schema v1은 strict UTF-8와 canonical re-encode byte equality를 만족하는 terminal
record만 읽을 수 있어야 한다.
- R1 terminal receipt는 기존 `PROCESS_LOCAL_SYNC` 보장 그대로 복원한다. - R1 terminal receipt는 기존 `PROCESS_LOCAL_SYNC` 보장 그대로 복원한다.
- R1 root-level artifact도 attested root의 `SecureDirectoryStream` 상대 no-follow bounded
streaming inspection으로 journal의 byte size와 SHA-256을 확인한 뒤에만 receipt를 복원한다.
- R1 artifact를 자동으로 R2 manifest/reference로 승격하지 않는다. - R1 artifact를 자동으로 R2 manifest/reference로 승격하지 않는다.
- R2 writer는 journal v2만 생성한다. - R2 writer는 journal v2만 생성한다.
- 기존 overwrite-capable legacy port는 별도 root와 opt-in을 유지하며 R2 control plane에 접근하지 - 기존 overwrite-capable legacy port는 별도 root와 opt-in을 유지하며 R2 control plane에 접근하지
@@ -334,9 +415,12 @@ matching data + private manifest + reference
- 설정/보장 mismatch: startup failure; - 설정/보장 mismatch: startup failure;
- destination 없음: producer 전 deterministic request failure; - destination 없음: producer 전 deterministic request failure;
- stage 이전 capacity/validation failure: not applied; - stage 이전 capacity/validation failure: not applied;
- stage/write failure: failed, partial stage는 recovery evidence가 아니면 정리; - stage/write failure: failed, partial stage는 recovery evidence가 아니면 exact cleanup하고
unsealed `QUARANTINED`로 producer replay를 차단;
- sealed 이후 filesystem timeout/IO/root identity change: indeterminate; - sealed 이후 filesystem timeout/IO/root identity change: indeterminate;
- published data와 metadata 불일치: integrity/quarantine; - non-terminal published data와 metadata 불일치: integrity/quarantine;
- terminal `PUBLISHED` data/metadata/receipt 불일치: terminal evidence 불변 보존 후 typed
integrity/indeterminate;
- journal/control record corruption: provider exception을 노출하지 않고 typed indeterminate; - journal/control record corruption: provider exception을 노출하지 않고 typed indeterminate;
- guarantee를 낮춰 성공시키는 fallback은 없다. - guarantee를 낮춰 성공시키는 fallback은 없다.
@@ -348,6 +432,8 @@ matching data + private manifest + reference
- R1/R2 simultaneous activation rejection; - R1/R2 simultaneous activation rejection;
- reference grammar/check digits/forged route rejection; - reference grammar/check digits/forged route rejection;
- journal v2, manifest, reference canonical round-trip; - journal v2, manifest, reference canonical round-trip;
- deterministic route token collision rejection과 canonical policy/schema/format digest;
- same operation path의 strict canonical R1 read-only/v2 write-only typed dispatch;
- state revision과 fingerprint conflict; - state revision과 fingerprint conflict;
- achieved durability value invariants. - achieved durability value invariants.
@@ -360,8 +446,10 @@ matching data + private manifest + reference
- successful capability probe와 cleanup; - successful capability probe와 cleanup;
- partial final visibility 0건; - partial final visibility 0건;
- same operation concurrency와 producer once; - same operation concurrency와 producer once;
- unsealed `WRITING` failure quarantine와 retry producer 0회;
- target collision no overwrite; - target collision no overwrite;
- data/manifest/reference digest mismatch quarantine. - non-terminal data/manifest/reference digest mismatch quarantine;
- terminal mismatch의 PUBLISHED journal/artifact 불변 보존과 typed integrity/indeterminate.
### 13.3 Crash qualification ### 13.3 Crash qualification
@@ -390,6 +478,11 @@ terminal journal directory force
partial final, overwrite, 다른 receipt, silent guarantee downgrade는 허용하지 않는다. partial final, overwrite, 다른 receipt, silent guarantee downgrade는 허용하지 않는다.
같은 attested root와 operation ID에 대해 process A가 OS operation lock을 보유하는 동안 forked
process B의 bounded non-blocking/timed acquire가 critical section에 진입하지 못하고, A의
release 또는 강제 종료 뒤 B가 획득하는지도 별도로 증명한다. 이 증거는 동일 JVM stripe 테스트로
대체하지 않는다.
### 13.4 플랫폼 ### 13.4 플랫폼
- Linux/POSIX + `SecureDirectoryStream` + directory force qualification lane에서만 - Linux/POSIX + `SecureDirectoryStream` + directory force qualification lane에서만
@@ -413,3 +506,41 @@ partial final, overwrite, 다른 receipt, silent guarantee downgrade는 허용
8. 문서와 receipt는 `local-persistent` qualification만 R2라고 표시한다. 8. 문서와 receipt는 `local-persistent` qualification만 R2라고 표시한다.
후속 순서는 Phase 3 maintenance/resource limits, Phase 4 SFTP, Phase 5 shared-mounted/NFS evidence다. 후속 순서는 Phase 3 maintenance/resource limits, Phase 4 SFTP, Phase 5 shared-mounted/NFS evidence다.
## 15. 구현 및 readiness 판정
2026-07-28 구현은 다음 경계를 만족한다.
- application에는 provider/path/framework 타입이 없는 `FilePublicationPort`만 유지한다.
- adapter 내부의 canonical operation/manifest/reference model, opaque reference, provider SPI,
exact destination router는 provider-neutral control/selection boundary로 구현되었다.
- `app.fileserver.enabled`는 disabled-default이며, enable 시 destination/provider를 exact
compile한다. Unknown destination은 producer 호출 전에 실패하고 implicit local fallback은
없다.
- 같은 provider ID를 참조하는 destination은 하나의 provider/control/payload runtime을
공유한다. 서로 다른 provider ID가 같은 normalized root를 소유하면 startup에서 실패한다.
- R2 provider는 `local-persistent` 하나만 구현·qualification한다. Absolute/existing
pre-provisioned root와 owner/mode/FileStore/sentinel/path/capability attestation이 모두
성공해야 bean이 구성된다.
- operation v2, private manifest, direct reference index, ordered force publication과
deterministic recovery를 구현했다. Forked-process qualification은 각 force boundary와 OS
operation lock을 대상으로 하며, focused/module/full gate 결과와 함께 완료 증거를 판정한다.
- 기존 schema-v1 terminal record와 root-level R1 artifact는 strict UTF-8/canonical/direct
read-only compatibility다. 원래 `PROCESS_LOCAL_SYNC` receipt만 복원하며 schema-v2 rewrite,
manifest/reference 생성, `FILE_AND_DIRECTORY_SYNC` 자동 승격을 하지 않는다.
`FILE_AND_DIRECTORY_SYNC`는 attested local filesystem protocol에서 file과 관련 directory
force가 성공했다는 의미다. Physical device, volatile storage-controller cache, volume replica,
backup 또는 site 단위 power-loss protection을 주장하지 않는다. 그 보장은 Fileserver 코드가
아니라 선택한 storage/deployment의 별도 evidence가 필요하다.
다음 capability는 구현되지 않았고 setting/env/bean으로 노출하지 않는다.
- `shared-mounted`/NFS multi-client correctness와 cross-node producer fencing;
- SFTP SDK, connection/session pool, host-key/credential, remote reconciliation;
- background reconcile/reaper, managed retention/delete;
- quota reservation, backpressure, capacity admission;
- Fileserver 전용 readiness/health, metrics, tracing, audit.
따라서 이 increment의 운영 claim은 “모든 Fileserver topology가 R2”가 아니라
“strictly attested `local-persistent` profile만 R2”다.
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
@@ -0,0 +1,137 @@
# Redis Cache Resilience Increment Design
**Status:** approved for implementation
**Parent:** `2026-07-26-redis-production-capability-design.md` §§15, 16, 18
## Goal
Complete one coherent production-facing cache increment on top of the current standalone R1 Redis
runtime:
1. a framework-free cache-aside policy in `application-core`;
2. bounded local single-flight and source bulkhead protection;
3. deterministic TTL jitter plus soft/hard expiry and stale lookup semantics in the Redis adapter.
This increment does not promote Redis beyond standalone cache R1. Distributed refresh leases,
generation invalidation, rate limiting, owner-safe locks, idempotency, sessions, Sentinel/Cluster,
TLS/ACL and fault qualification remain later increments.
## Architecture boundary
- `application-core` owns lookup interpretation, source-result classification, cache-aside
sequencing, stale-if-error, local coalescing and source admission policy.
- `adapter:outbound:cache-redis` owns physical TTL, envelope timestamps, deterministic jitter,
serialization and Redis command outcomes.
- The application contract contains no Redis/Lettuce/Lua/Spring type.
- Cache fallback never becomes unlimited source fallback. A miss, provider outage and waiter burst
all pass through the same bounded source path.
## Application contract
`CacheSourceLoader<K,V>` returns a typed `SourceLoadOutcome<V>`:
- `Loaded(value, sourceRevision)`;
- `AuthoritativeAbsent(reason, sourceRevision)`;
- `TransientFailure(SourceFailure)`;
- `PermanentFailure(SourceFailure)`;
- `Cancelled`.
`SourceFailure` carries a bounded code and the original cause. It never serializes the cause message
into Redis or metric tags. An unclassified thrown exception is rethrown unchanged and is never
negative-cached or converted to stale success.
`CacheResult<V>` distinguishes:
- fresh cache hit;
- source-loaded value and its cache-record outcome;
- authoritative absence and its cache-record outcome;
- stale fallback after a classified transient source failure;
- source failure;
- bounded overload/timeout rejection;
- cancellation.
`CacheAsidePolicy` is immutable and constructed once per semantic region. It contains maximum
in-flight source keys, waiter limit per key, source concurrency, admission wait, load deadline and
whether transient source failure may serve stale.
## Cache-aside state machine
1. `Hit(FRESH)` returns immediately.
2. `NegativeHit` returns immediately.
3. `Hit(STALE)` retains the value and attempts a bounded refresh.
4. `Miss`, an `IncompatibleSchema(QUARANTINE_AND_RELOAD)` carrying a usable opaque observation
token, and `Unavailable` enter the same bounded source path. `FAIL_FAST` schema results and
unobservable incompatible values are not overwritten.
5. A local single-flight elects one leader per semantic key. Waiters share the typed source outcome.
6. The leader must acquire the source bulkhead before calling the loader.
7. A miss records with `ONLY_IF_ABSENT`. A stale or quarantined observation records with
`ONLY_IF_OBSERVED`, which atomically compares the digest captured by lookup before replacing the
value. No lookup-then-delete sequence is used, so a concurrent writer is never deleted.
8. Only `AuthoritativeAbsent` records a negative entry, using the same absent/observed condition as
a positive source result.
9. `TransientFailure` may return the retained stale value when policy allows it.
10. `PermanentFailure`, unclassified exceptions and cancellation are never hidden by negative cache.
11. Entries are removed from the flight map after success or failure. In-flight keys and waiters are
bounded; waiting uses a finite deadline and preserves thread interruption.
The loader is synchronous and cancellation is cooperative. Its token exposes deadline/interruption;
the executor bounds admission and waiter time but cannot safely terminate arbitrary source code.
## Redis envelope and TTL policy
The positive envelope moves to version 2 and stores:
- source revision;
- `softExpiresAt` epoch milliseconds;
- `hardExpiresAt` epoch milliseconds;
- payload and SHA-256 integrity digest.
Negative envelopes store only the hard expiry. Lookup behavior is:
- `now < softExpiresAt`: `Hit(FRESH)`;
- `softExpiresAt <= now < hardExpiresAt`: `Hit(STALE)`;
- `now >= hardExpiresAt`: `Miss(EXPIRED)`;
- negative `now < hardExpiresAt`: `NegativeHit`;
- expired negative: `Miss(EXPIRED)`.
Version 1 becomes an explicit retired schema result. Future versions and corrupt envelopes fail
fast. Digest-valid retired/unknown envelopes carry an opaque observation token so an approved
quarantine reload can compare-and-replace the exact observation. Structurally invalid current
envelopes remain corrupt/fail-fast even when their digest is valid. Unknown envelopes remain typed
incompatibility results and are not silently treated as misses. Envelope integrity is checked
before the version byte is trusted.
The policy contains positive soft TTL, positive hard TTL, negative TTL, jitter ratio, minimum hard
TTL and maximum value bytes. Construction rejects:
- non-positive or over-30-day TTLs;
- soft TTL greater than hard TTL;
- jitter outside `0.0..0.5`;
- minimum hard TTL greater than either configured hard TTL.
- configured hard TTL plus maximum positive jitter greater than 30 days.
Jitter is deterministic from the HMAC-derived physical key and the compiled policy revision. It
uses a symmetric bounded factor. The actual positive soft/hard TTLs use the same factor so ordering
is preserved. Physical Redis TTL equals the encoded hard expiry duration in the same `SET`.
Negative TTL is jittered independently and also respects the hard minimum.
## Evidence
Tests must prove:
- fresh/negative hits do not call the source;
- concurrent same-key misses call the loader once;
- in-flight-key, waiter, bulkhead and deadline bounds;
- completion/failure cleanup and exception/interruption behavior;
- only authoritative absence is negative-cached;
- stale is served only after a classified transient failure;
- fresh/stale/expired boundaries with an injected `Clock`;
- deterministic bounded jitter and hard minimum;
- version 1/future/corrupt envelope behavior;
- Redis physical TTL matches the encoded hard expiry.
- observed replace reads only the trailing digest and never overwrites a concurrent writer;
- the exact 16MiB opt-in payload is accepted while 16MiB+1 is rejected before dispatch;
- mutation interruption restores the thread flag and maps to indeterminate certainty.
Focused checks run before the repository-wide architecture, dependency, env and public-path gates.
@@ -0,0 +1,144 @@
# Redis Distributed Rate-Limit Increment Design
**Status:** implemented as standalone R1
**Parent:** `2026-07-26-redis-production-capability-design.md` §§1921
## Goal and readiness
Provide three selectable, bounded distributed rate-limit algorithms:
- fixed window;
- sliding-window counter;
- token bucket.
This increment is a standalone Redis R1 provider. It does not claim R2 topology/security/failover
qualification and does not implement sliding log, GCRA, leaky bucket, evaluation dedup, hierarchical
all-or-nothing policies or local emergency fallback.
## Ownership
- `shared-contract` owns the edge-enforcement semantic port and provider-neutral request, policy,
decision and failure outcomes. Business quotas remain application use-case policy and do not use
this port.
- `adapter:outbound:cache-redis` owns Redis keys, atomic Lua programs, structured reply parsing,
failure certainty and the provider implementation.
- `app-bootstrap` owns the explicit provider/policy selection.
- The existing inbound-web local limiter remains a compatibility path until a separate inbound
migration. Its types do not cross into the Redis provider.
The rate-limit runtime does not reuse `app.cache.redis`, the cache connection or cache fail-open
decorators. Coordination has different failure and deployment semantics.
## Shared semantic contract
`EdgeRateLimitPort.evaluate(RateLimitRequest)` accepts:
- bounded `policyId`;
- already pseudonymized/bounded `subjectDigest`;
- positive request cost;
- optional evaluation ID (rejected in this non-deduplicating revision);
- finite caller deadline.
`RateLimitPolicy` freezes policy ID/revision, one algorithm-specific parameter subtype, maximum
cost, cleanup grace, maximum clock regression and `FAIL_CLOSED`. Construction rejects mismatched
algorithm/parameters, arithmetic outside Lua's exact integer range and unsupported failure/dedup
claims.
The outcome is one of:
- `Evaluated(decision)`;
- `Unavailable(policyId, retryAfter, category)` for known pre-send/no-mutation failures and unsafe
server clock;
- `Indeterminate(policyId, retryAfter)` for post-dispatch uncertain mutation;
- `Incompatible(policyId, category)` for state/program/reply mismatch.
`RateLimitDecision` includes allow/deny, limit, remaining, retry-after, reset-at, policy ID/revision,
`GLOBAL_REDIS` source and certainty. Fixed window and token bucket are `CERTAIN`;
sliding-window counter is `APPROXIMATE_ALGORITHM`.
## Atomic programs
Each v1 program uses one versioned hash key and calls Redis `TIME` exactly once.
```text
rate-fixed-window-v1.lua
rate-sliding-counter-v1.lua
rate-token-bucket-v1.lua
```
Every program returns exactly seven bounded scalar fields:
```text
status, serverNowMillis, effectiveNowMillis,
limit, remaining, retryAfterMillis, resetAtMillis
```
Statuses are `ALLOWED`, `DENIED`, `CLOCK_UNSAFE`, `STATE_INCOMPATIBLE`, `INVALID`.
Unknown arity/status/numeric syntax/range is a compatibility failure, never allow/fail-open.
Common rules:
- Redis server time drives enforcement;
- small backward movement clamps to stored `lastObservedMillis`;
- regression beyond policy threshold returns `CLOCK_UNSAFE` without consuming state;
- policy/schema/algorithm mismatch returns `STATE_INCOMPATIBLE`;
- denied requests do not consume quota;
- state receives a finite TTL;
- all arithmetic stays within `2^53-1`;
- raw principal/IP/API-key/route never appears in the physical key.
The existing scalar Lua executor stays intact. A structured program path adds bounded MULTI reply
support and uses `EVALSHA`, falling back to the exact compiled source only on `NOSCRIPT`.
## Algorithm rules
Fixed window stores window ID and consumed count. Allow increments only when
`consumed + cost <= limit`; retry/reset points to the current window end.
Sliding counter stores previous/current window IDs and counts, using scale `1_000_000` and
conservative ceiling weight. It reports approximate certainty and a bounded conservative retry.
Token bucket stores scaled tokens, last refill time and the sub-token division remainder. Refill is
therefore independent of evaluation frequency, uses quotient/remainder arithmetic without an
unsafe `numerator + denominator - 1` intermediate, and saturates at capacity. Denial does not
subtract tokens; retry and full-reset use integer ceiling.
## Physical key
The existing canonical builder is reused with:
```text
capability=rate
region=<policyId>
kind=state
digest(policyId, policyRevision, algorithm, subjectDigest)
```
Policy revision appears in both digest input and stored state. A policy revision therefore rolls to
a new key while old state expires naturally.
## Runtime and composition
`app.rate-limit` is disabled by default. Enabling requires:
- `provider=redis`;
- one default policy and an exact policy definition;
- a dedicated Redis coordination endpoint and HMAC secret;
- finite command/admission bounds.
Only `role=coordination` and `failure-policy=fail-closed` are accepted in v1. Disabled mode creates
no connection, thread or semantic port. Cache Redis settings/beans are never an implicit fallback.
## Evidence
Unit tests cover contract bounds, policy arithmetic, key privacy/revision, structured reply
validation, `NOSCRIPT`, boundary vectors, denial-no-consume, clock regression, pre/post-dispatch
failure certainty and disabled composition. The explicit Redis 7.4 service lane executes all three
programs, exact-boundary admission after a denied non-consuming request, excessive clock-regression
state immutability, `TYPE` response normalization, token refill-remainder carry, malformed hash-state
classification, cache `NX`, and observation-token compare-and-replace. Redis 7.4 is the minimum
version declared by the program manifests until a lower-version service lane exists. The caller
deadline is an admission precheck against the fixed command timeout; R1 does not claim per-command
dynamic timeout or hard cancellation after dispatch. Missing TLS/ACL, Sentinel/Cluster, failover and
persistence/eviction evidence keeps the provider at R1.
+134
View File
@@ -0,0 +1,134 @@
# Disposable Redis qualification lab
This directory owns the lifecycle contract for the isolated three-node k3s lab. It does not contain
Redis workloads, credentials, certificates, or qualification evidence.
## Fixed topology
| Instance | CPU | Memory | Disk | Role |
| --- | ---: | ---: | ---: | --- |
| `ca-redis-lab-server` | 2 | 3G | 12G | k3s server |
| `ca-redis-lab-agent-1` | 2 | 2560M | 12G | k3s agent |
| `ca-redis-lab-agent-2` | 2 | 2560M | 12G | k3s agent |
The lab uses pod CIDR `10.52.0.0/16`, service CIDR `10.53.0.0/16`, and context
`ca-redis-lab`. `versions.env` pins the k3s version and Multipass image. Traefik and ServiceLB are
disabled.
## Safety model
All state, rendered cloud-init, kubeconfigs, tokens, and raw observations are mode-restricted
beneath the ignored `src/build/redis-lab` directory. Every canonical ancestor from the repository
root through `src/build/redis-lab`, plus runtime children, is validated before observation or
mutation; a symlink or real-path escape fails closed. The lifecycle never exports `KUBECONFIG`,
merges a kubeconfig, or writes the user's default kubeconfig.
Host observation and lab access deliberately use different explicit targets:
- host read-only queries copy the default kubeconfig into
`src/build/redis-lab/observations/host-kubeconfig` and use its unchanged original context;
- lab read-only queries use `src/build/redis-lab/kubeconfig` and exact context `ca-redis-lab`.
This split preserves the host context identity while ensuring no kubectl call relies on an implicit
target. Host kubectl mutations are not part of the lifecycle. The observation-only host copy is
removed after fingerprint and CIDR observation on success and every handled failure path.
Each exact name gets a private mode-`0600` rendered cloud-init file beneath
`src/build/redis-lab/cloud-init`. It writes only the non-secret ownership marker
`RUN_ID|VM_NAME` to `/var/lib/ca-redis-lab/ownership` as `root:root` mode `0600`; launch uses only
that rendered file.
Each name is then atomically reserved as `PENDING` in `run.state` before its bounded launch. The
state starts with an exact per-run identity, and each `PENDING`/`CREATED`/`RECONCILE` entry carries
that same identity. A successful launch becomes `CREATED` only after a bounded
`multipass exec <name> -- sudo cat /var/lib/ca-redis-lab/ownership` returns the exact marker.
Timeout, launch error, missing/foreign marker, signal, promotion failure, or uncertain cleanup
enters `RECONCILE`.
Cleanup transitions a recorded entry to `RECONCILE`, bounded-polls the exact instance and marker,
and issues `multipass delete --purge <exact-name>` only after the marker matches. It atomically
removes only an entry whose delete succeeded. A late-created matching instance is deleted; an
absent instance, unreadable marker, mismatched/foreign marker, or failed delete is retained as a
tombstone and fails closed without an unproven delete. Existing instance-bearing state blocks a
new `preflight`, `up`, or `run`; a rejected new run does not clean the prior run, and `down` is the
retry/reconciliation entry point. Existing
allowlisted names without owned state cause `up` to stop before reservation/launch and are never
adopted or deleted. Wildcards, `--all`, global purge, and discovered-instance deletion are
forbidden.
The lifecycle lock is nonblocking and exclusive. External children close its descriptor by
default, including detached infrastructure descendants and `run --` commands; only lock
acquisition retains descriptor 9.
Multipass list/launch/info/exec/transfer/delete, installation/join, and kubectl calls have fixed
time bounds. `up` succeeds only after the exact server and two agents all report `Ready=True`
within the bounded poll budget; incomplete or not-ready inventory enters marker-proven run-owned
cleanup.
The k3s runtime is amd64-only and fail-closed in this slice. `versions.env` pins the immutable
release URL and exact SHA-256 for `v1.33.3+k3s1`. The lifecycle performs a bounded host download,
verifies the digest, transfers the binary to each exact VM, verifies the transferred digest and
reported binary version inside each VM, and only then installs/starts it. It does not execute a
network installer or a `curl | sh` pipeline.
The generated lab kubeconfig is accepted only in the pinned single-cluster/single-context/
single-user block grammar. A tracked AWK state machine has one explicit transition for every
allowlisted line and publishes no output until the complete document reaches its exact final
state. It rejects missing, duplicate, reordered, unknown, whitespace-altered, quoted, tagged, or
explicit keys; anchors, aliases, merge keys, tabs, CRLF, document markers, trailing content, and
all flow collections except exact `preferences: {}`. Only the exact cluster/context/user identity,
`current-context`, and loopback API server are rewritten. CA data, client certificate/key data,
and an optional canonical namespace are byte-preserved.
Rendering uses a same-directory `kubeconfig.next`, applies mode `0600`, and replaces the
destination only after render and permission success. The renderer must be a readable regular
non-symlink file at its canonical tracked path, and both destination paths are protected by the
runtime symlink contract. Renderer, permission, or move failure removes both candidate and
destination, performs no lab `kubectl`, and enters exact marker-proven current-run cleanup.
Assigned Service ClusterIPs cannot prove the host service CIDR. When a host kubeconfig exists,
callers must supply one or more canonical, comma- or space-separated IPv4 CIDRs through
`REDIS_LAB_HOST_SERVICE_CIDRS`. Missing, malformed, or overlapping input fails before launch:
```bash
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 \
infra/redis-lab/bin/redis-lab preflight
```
## Commands
The real lifecycle is for a trusted local or dedicated runner only:
```bash
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab preflight
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab up
infra/redis-lab/bin/redis-lab down
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab run -- command
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 \
infra/redis-lab/bin/redis-lab run --retain-on-failure -- command
```
`run` establishes its cleanup obligation before entering the inner `up`, keeps it through the
post-up/pre-command handoff and user command, then tears down after command success or failure and
compares the canonical pre/post host fingerprints after cleanup. A successful direct `up` retains
the lab by design. Local `--retain-on-failure` intentionally leaves the recorded lab for diagnosis
and skips an isolation-success claim; `CI=true` rejects that option before launch.
The blocking contract is VM-free:
```bash
cd src
./gradlew :adapter:outbound:cache-redis:redisLabContractTest --console=plain
```
It injects fake infrastructure commands. Hosted CI must run only this contract, never the real lab.
The contract executes a copied lifecycle in
`src/build/redis-lab-contract/repository`, seals `PATH` to explicit fakes/safe wrappers, and compares
a byte-level snapshot proving it did not modify the real repository's `src/build/redis-lab`. It
also exercises direct/run signal cleanup, rendered-child symlink rejection, successful and
late-create marker proof, absent/foreign-marker tombstones, second-run state preservation,
CREATED cleanup uncertainty, rejected-run preservation of prior `CREATED` and `RECONCILE` state,
the post-up/pre-command signal handoff, the canonical kubeconfig mutation matrix, missing/symlinked
renderer rejection, fail-closed `.next`/permission/move publication, and infrastructure/user
background-child lock non-inheritance. This is deterministic fake-runtime evidence only; it is not
live Multipass, k3s, kubectl, network, or host-isolation qualification.
+1540
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
#cloud-config
package_update: false
package_upgrade: false
ssh_pwauth: false
disable_root: true
write_files:
- path: /etc/sysctl.d/90-ca-redis-lab.conf
owner: root:root
permissions: "0644"
content: |
net.ipv4.ip_forward=1
runcmd:
- [mkdir, -p, /etc/rancher/k3s]
- [sysctl, --system]
+194
View File
@@ -0,0 +1,194 @@
BEGIN {
state = "start"
invalid = 0
output_count = 0
if (target != "ca-redis-lab") {
invalid = 1
}
}
function remember(line) {
output[++output_count] = line
}
function is_credential_line(line, prefix, value) {
if (index(line, prefix) != 1) {
return 0
}
value = substr(line, length(prefix) + 1)
return value ~ /^[A-Za-z0-9+\/=_-]+$/
}
function is_namespace_line(line, value) {
if (index(line, " namespace: ") != 1) {
return 0
}
value = substr(line, length(" namespace: ") + 1)
return value ~ /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/
}
{
if (invalid) {
next
}
if (index($0, "\t") != 0 || index($0, "\r") != 0 ||
$0 ~ /^[[:space:]]*(---|\.\.\.)([[:space:]]|$)/) {
invalid = 1
next
}
if ($0 != "preferences: {}" &&
(index($0, "{") != 0 || index($0, "}") != 0 ||
index($0, "[") != 0 || index($0, "]") != 0)) {
invalid = 1
next
}
if (state == "start" && $0 == "apiVersion: v1") {
api_version_count += 1
state = "apiVersion"
remember($0)
next
}
if (state == "apiVersion" && $0 == "clusters:") {
clusters_count += 1
state = "clusters"
remember($0)
next
}
if (state == "clusters" && $0 == "- cluster:") {
cluster_item_count += 1
state = "cluster-item"
remember($0)
next
}
if (state == "cluster-item" &&
is_credential_line($0, " certificate-authority-data: ")) {
ca_data_count += 1
state = "ca-data"
remember($0)
next
}
if (state == "ca-data" &&
$0 == " server: https://127.0.0.1:6443") {
server_count += 1
state = "server"
remember(" server: https://" address ":6443")
next
}
if (state == "server" && $0 == " name: default") {
cluster_name_count += 1
state = "cluster-name"
remember(" name: " target)
next
}
if (state == "cluster-name" && $0 == "contexts:") {
contexts_count += 1
state = "contexts"
remember($0)
next
}
if (state == "contexts" && $0 == "- context:") {
context_item_count += 1
state = "context-item"
remember($0)
next
}
if (state == "context-item" && $0 == " cluster: default") {
context_cluster_count += 1
state = "context-cluster"
remember(" cluster: " target)
next
}
if (state == "context-cluster" && is_namespace_line($0)) {
namespace_count += 1
state = "optional-namespace"
remember($0)
next
}
if ((state == "context-cluster" || state == "optional-namespace") &&
$0 == " user: default") {
context_user_count += 1
state = "context-user"
remember(" user: " target)
next
}
if (state == "context-user" && $0 == " name: default") {
context_name_count += 1
state = "context-name"
remember(" name: " target)
next
}
if (state == "context-name" && $0 == "current-context: default") {
current_context_count += 1
state = "current-context"
remember("current-context: " target)
next
}
if (state == "current-context" && $0 == "kind: Config") {
kind_count += 1
state = "kind"
remember($0)
next
}
if (state == "kind" && $0 == "preferences: {}") {
preferences_count += 1
state = "preferences"
remember($0)
next
}
if (state == "preferences" && $0 == "users:") {
users_count += 1
state = "users"
remember($0)
next
}
if (state == "users" && $0 == "- name: default") {
user_name_count += 1
state = "user-name"
remember("- name: " target)
next
}
if (state == "user-name" && $0 == " user:") {
user_body_count += 1
state = "user-body"
remember($0)
next
}
if (state == "user-body" &&
is_credential_line($0, " client-certificate-data: ")) {
client_cert_count += 1
state = "client-cert"
remember($0)
next
}
if (state == "client-cert" &&
is_credential_line($0, " client-key-data: ")) {
client_key_count += 1
state = "client-key"
remember($0)
next
}
invalid = 1
}
END {
if (invalid || state != "client-key" ||
api_version_count != 1 || clusters_count != 1 ||
cluster_item_count != 1 || ca_data_count != 1 ||
server_count != 1 || cluster_name_count != 1 ||
contexts_count != 1 || context_item_count != 1 ||
context_cluster_count != 1 || namespace_count > 1 ||
context_user_count != 1 || context_name_count != 1 ||
current_context_count != 1 || kind_count != 1 ||
preferences_count != 1 || users_count != 1 ||
user_name_count != 1 || user_body_count != 1 ||
client_cert_count != 1 || client_key_count != 1) {
exit 1
}
for (line_number = 1; line_number <= output_count; line_number += 1) {
print output[line_number]
}
}
@@ -0,0 +1,20 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: preserve-default-ca-canary
server: https://192.0.2.10:6443
name: ca-redis-lab
contexts:
- context:
cluster: ca-redis-lab
namespace: team-default
user: ca-redis-lab
name: ca-redis-lab
current-context: ca-redis-lab
kind: Config
preferences: {}
users:
- name: ca-redis-lab
user:
client-certificate-data: preserve-default-client-cert-canary
client-key-data: preserve-default-client-key-canary
@@ -0,0 +1,19 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: preserve-default-ca-canary
server: https://192.0.2.10:6443
name: ca-redis-lab
contexts:
- context:
cluster: ca-redis-lab
user: ca-redis-lab
name: ca-redis-lab
current-context: ca-redis-lab
kind: Config
preferences: {}
users:
- name: ca-redis-lab
user:
client-certificate-data: preserve-default-client-cert-canary
client-key-data: preserve-default-client-key-canary
@@ -0,0 +1,19 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: preserve-default-ca-canary
server: https://127.0.0.1:6443
name: default
contexts:
- context:
cluster: default
user: default
name: default
current-context: default
kind: Config
preferences: {}
users:
- name: default
user:
client-certificate-data: preserve-default-client-cert-canary
client-key-data: preserve-default-client-key-canary
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
K3S_VERSION=v1.33.3+k3s1
MULTIPASS_IMAGE=24.04
K3S_AMD64_URL=https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s
K3S_AMD64_SHA256=f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc
+43 -18
View File
@@ -12,9 +12,20 @@ APP_ERROR_DETAIL_EXPOSURE_ENABLED=false
APP_LOG_BODY_CAPTURE_ENABLED=false APP_LOG_BODY_CAPTURE_ENABLED=false
APP_MULTI_INSTANCE_ENABLED=false APP_MULTI_INSTANCE_ENABLED=false
APP_MIGRATION_ON_STARTUP=true APP_MIGRATION_ON_STARTUP=true
APP_RATE_LIMIT_ENABLED=true APP_RATE_LIMIT_ENABLED=false
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
APP_RATE_LIMIT_PROVIDER=disabled
APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_TTL=24h APP_IDEMPOTENCY_TTL=24h
APP_IDEMPOTENCY_PROVIDER=jdbc
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT=local
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
APP_LEASE_PROVIDER=disabled
APP_LEASE_REDIS_KEY_HMAC_SECRET=
APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT=local
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
# ----- Async executor ----- # ----- Async executor -----
APP_ASYNC_EXECUTOR_CORE_SIZE=10 APP_ASYNC_EXECUTOR_CORE_SIZE=10
@@ -22,6 +33,13 @@ APP_ASYNC_EXECUTOR_MAX_SIZE=50
APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200 APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
# ----- Optional integration adapters (default: all disabled) ----- # ----- Optional integration adapters (default: all disabled) -----
APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled
# Sentinel primary revalidation cadence for canonically active Sentinel roles.
APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD=30s
# Canonical role semantic readiness: refresh no more often than this interval.
APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL=5s
# Fail closed when the last completed semantic observation is older than this bound.
APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS=15s
APP_CACHE_REDIS_ENABLED=false APP_CACHE_REDIS_ENABLED=false
APP_CACHE_REDIS_CLIENT_MODE=managed APP_CACHE_REDIS_CLIENT_MODE=managed
APP_CACHE_REDIS_HOST=localhost APP_CACHE_REDIS_HOST=localhost
@@ -34,6 +52,13 @@ APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216
APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local
APP_CACHE_REDIS_SEMANTIC_REGION=default APP_CACHE_REDIS_SEMANTIC_REGION=default
APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576 APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576
APP_CACHE_REDIS_L1_ENABLED=false
APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES=10000
APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES=67108864
APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES=1048576
APP_CACHE_REDIS_L1_TTL=30s
APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL=5s
APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY=1024
APP_CACHE_DEFAULT_TTL=300s APP_CACHE_DEFAULT_TTL=300s
APP_CACHE_NEGATIVE_TTL=60s APP_CACHE_NEGATIVE_TTL=60s
APP_MESSAGING_BROKER= APP_MESSAGING_BROKER=
@@ -41,23 +66,6 @@ APP_MESSAGING_KAFKA_BROKERS=
APP_NOTIFICATION_SLACK_PROVIDER= APP_NOTIFICATION_SLACK_PROVIDER=
APP_NOTIFICATION_EMAIL_PROVIDER= APP_NOTIFICATION_EMAIL_PROVIDER=
# ----- Outbound HTTP client -----
APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s
APP_OUTBOUND_HTTP_READ_TIMEOUT=5s
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_MAX_ATTEMPTS=3
APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms
APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER=2.0
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED=false
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD=50
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE=100
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS=100
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE=60s
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN=10
APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT=10MB
# ----- Logging: root & app levels ----- # ----- Logging: root & app levels -----
APP_LOG_LEVEL_ROOT=INFO APP_LOG_LEVEL_ROOT=INFO
APP_LOG_LEVEL_APP=DEBUG APP_LOG_LEVEL_APP=DEBUG
@@ -130,9 +138,26 @@ APP_SERVER_ERROR_INCLUDE_MESSAGE=never
PRESENTATION_API_BASE_PATH=/api PRESENTATION_API_BASE_PATH=/api
# ----- Auth (OIDC resource server) ----- # ----- Auth (OIDC resource server) -----
APP_SECURITY_AUTH_MODE=jwt
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
SECURITY_PUBLIC_PATHS=/api/healthcheck SECURITY_PUBLIC_PATHS=/api/healthcheck
APP_SESSION_COOKIE_NAME=CA_SESSION
APP_SESSION_COOKIE_SECURE=true
APP_SESSION_COOKIE_HTTP_ONLY=true
APP_SESSION_COOKIE_SAME_SITE=Lax
APP_SESSION_COOKIE_PATH=/
APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN
APP_SESSION_CSRF_HEADER_NAME=X-XSRF-TOKEN
APP_SESSION_REDIS_KEY_HMAC_SECRET=
APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT=local
APP_SESSION_IDLE_TIMEOUT=30m
APP_SESSION_ABSOLUTE_LIFETIME=8h
APP_SESSION_TOUCH_INTERVAL=1m
APP_SESSION_TOMBSTONE_TTL=5m
APP_SESSION_MAXIMUM_ENVELOPE_BYTES=32768
APP_SESSION_MAXIMUM_ATTRIBUTES=64
APP_SESSION_MAXIMUM_SCALAR_BYTES=8192
# ----- CORS ----- # ----- CORS -----
APP_SECURITY_CORS_ENABLED=true APP_SECURITY_CORS_ENABLED=true
+29 -28
View File
@@ -204,8 +204,9 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
**`prod` 에서는 반드시 `false`**, 아니면 기동 실패. **`prod` 에서는 반드시 `false`**, 아니면 기동 실패.
- **`APP_MULTI_INSTANCE_ENABLED`** — `true` 면 인스턴스 협조용 빈 5종(lock / cache-stampede / - **`APP_MULTI_INSTANCE_ENABLED`** — `true` 면 인스턴스 협조용 빈 5종(lock / cache-stampede /
leader / rate-limit / migration)이 모두 있어야 하며, 하나라도 없으면 기동이 실패합니다. leader / rate-limit / migration)이 모두 있어야 하며, 하나라도 없으면 기동이 실패합니다.
- **`APP_RATE_LIMIT_ENABLED`** — fixed-window rate-limit interceptor 활성화 - **`APP_RATE_LIMIT_ENABLED`** — provider-neutral edge rate-limit interceptor 활성화
(429 + `Retry-After` + `X-RateLimit-*` 응답). (429 + `Retry-After` + `X-RateLimit-*` 응답). 기본값은 `false`이며, `true`로 바꿀 때는
`APP_RATE_LIMIT_PROVIDER=redis`와 canonical coordination role을 함께 구성해야 합니다.
- **`APP_RATE_LIMIT_CLIENT_IP_MODE`** — 클라이언트 IP 판별 방식. `remote-addr-only` | - **`APP_RATE_LIMIT_CLIENT_IP_MODE`** — 클라이언트 IP 판별 방식. `remote-addr-only` |
`forwarded-headers-trusted`. **신뢰된 ingress/LB 가 `X-Forwarded-For` 를 앱 도달 전에 덮어쓸 때만** `forwarded-headers-trusted`. **신뢰된 ingress/LB 가 `X-Forwarded-For` 를 앱 도달 전에 덮어쓸 때만**
`forwarded-headers-trusted` 를 쓰세요. 아니면 IP 위조에 노출됩니다. `forwarded-headers-trusted` 를 쓰세요. 아니면 IP 위조에 노출됩니다.
@@ -228,6 +229,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_CANONICAL_DEFAULT_PROVIDER`** — canonical default semantic region 선택.
`disabled`(기본) | `redis`. `redis`는 canonical Redis CACHE role binding을 함께 요구합니다.
- **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가 - **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가
제공한 `RedisClient` bean을 사용합니다. 제공한 `RedisClient` bean을 사용합니다.
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시 - **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시
@@ -240,32 +243,30 @@ fail-fast sentinel 이 포트를 충족합니다(Layer 3).
### Outbound HTTP client ### Outbound HTTP client
- **결정 — timeout 은 필수(D5).** timeout 미설정 또는 무한 timeout 은 금지이며, 기동 시 0 이 아닌 값을 현재 canonical activation은 다음 두 설정 트리만 사용합니다.
강제합니다. 무한 timeout 은 네트워크 호출이 영원히 매달릴 수 있어 런타임 장애가 아니라 설정 실수로
보고 즉시 기동을 실패시킵니다. ```yaml
- **`APP_OUTBOUND_HTTP_CONNECT_TIMEOUT`** — TCP connect timeout. duration(예: `2s`), 필수, non-zero. ca-skeleton:
- **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero. capabilities:
- **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예: http-client:
`10s`), 필수, non-zero. expected-state: DISABLED
- **`APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS`** — client별 살아 있는 logical-call worker 상한. bindings: {}
기본값 `128`, 허용 범위 `1..10000`. providers:
- **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이 http-client: {}
있어야 하며(D3 가드), 없으면 기동 실패. ```
- retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존):
- **`..._RETRY_MAX_ATTEMPTS`** — 총 시도 횟수(최초 시도 포함). 1 이상 정수. - 기본 `DISABLED`는 binding/provider definition이 모두 비어 있어야 하며
- **`..._RETRY_INITIAL_BACKOFF`** — exponential backoff 시작 간격. duration, non-zero. `DISABLED_VERIFIED`만 게시하고 client, executor, pool, retry/CB registry를 만들지 않습니다.
- **`..._RETRY_BACKOFF_MULTIPLIER`** — backoff 배수. 1.0 이상 double. - `ACTIVE`는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한
- **`APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED`** — circuit breaker on/off. `true` 로 켜면 buffered-classic readiness card가 `NOT_IMPLEMENTED`이므로 provider resource 생성 전에
`MeterRegistry` 빈 필요(D3), 없으면 기동 실패. fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다.
- circuit breaker 튜닝(아래는 `circuit-breaker-enabled=true` 일 때 적용, 기본값은 Resilience4j - 기존 `APP_OUTBOUND_HTTP_*``app.outbound.http.*`는 canonical 설정이 아닙니다. `.env`,
`ofDefaults()`): application YAML과 env-key registry에서 제거됐으며 canonical composition에 입력하면 상태와
- **`..._FAILURE_RATE_THRESHOLD`** — open 으로 전환되는 실패율 임계치(%). (0, 100] 범위 float. 무관하게 기동을 거부합니다.
- **`..._SLIDING_WINDOW_SIZE`** — COUNT_BASED sliding window 크기. 1 이상 정수. - legacy JDK facade가 필요한 fork만 canonical composition 밖에서
- **`..._MINIMUM_NUMBER_OF_CALLS`** — 실패율 계산을 시작하는 최소 호출 수. 1 이상 정수. `OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
- **`..._WAIT_DURATION_IN_OPEN_STATE`** — open 상태 유지 시간. duration, non-zero. timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider
- **`..._PERMITTED_CALLS_IN_HALF_OPEN`** — half-open 에서 허용하는 시험 호출 수. 1 이상 정수. readiness를 증명하지 않습니다.
- **`APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT`** — 메모리에 받는 응답 본문 최대 크기(예: `10MB`). 이를
넘는 응답은 streaming API 를 써야 합니다(D7).
### Logging ### Logging
+39 -25
View File
@@ -47,9 +47,24 @@ production configuration and compare the result with the committed snapshot.
### JwtToAuthenticatedPrincipalConverter ### JwtToAuthenticatedPrincipalConverter
- `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며 - `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며
`ObjectOutputStream` 으로 round-trip 되지 않는다(이 템플릿엔 Java-직렬화 세션 저장소가 없음 — grep 확인). `ObjectOutputStream` 으로 round-trip 되지 않는다. Redis session mode에서도 아래 primitive snapshot
repository가 `Authentication` 객체 그래프를 저장하지 않는다.
Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다. Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다.
### JWT / Redis session 상호배타 모드
`ca-skeleton.security.auth-mode=jwt|redis-session`은 하나만 선택한다. JWT mode는 stateless이고
CSRF/session repository를 만들지 않는다. Redis session mode는 `Secure`, `HttpOnly`, host-only
session cookie, `SameSite=Lax`, cookie/header CSRF와 `migrateSession` fixation 방어를 함께 켠다.
기본 `HttpSessionSecurityContextRepository`는 Spring Security 객체 전체를 session attribute에 넣어
outbound session codec의 primitive allowlist를 깨므로 사용하지 않는다.
`PrimitiveSessionSecurityContextRepository``AuthenticatedPrincipal`의 bounded
principal/email/roles/authorities만 versioned `byte[]` snapshot으로 저장한다. credential, bearer/JWT,
arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지 않는다. foreign principal이나
손상·초과 snapshot은 인증 없음으로 fail closed한다. 실제 security filter save/restore 테스트가 다음
요청에서 principal과 authorities가 복원되고 session에는 primitive snapshot만 남는 것을 검증한다.
### SecurityErrorClassifier ### SecurityErrorClassifier
- AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가 - AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가
선언한 세분화 코드를 방출한다. 선언한 세분화 코드를 방출한다.
@@ -234,22 +249,20 @@ production configuration and compare the result with the committed snapshot.
## ratelimit ## ratelimit
### 알고리즘 seam (RateLimiter / RateLimiterFactory / RateLimitAlgorithm / FixedWindowRateLimiter) ### provider-neutral edge contract
- 알고리즘은 프로젝트마다 바뀔 수 있는 운영 선택이라 `RateLimiter` 인터페이스 뒤에 둔다.
- **OCP(개방-폐쇄)**: `RateLimitInterceptor``RateLimiter` 타입에만 의존하고, `RateLimiterFactory` 의 단일 - inbound web은 `shared-contract``EdgeRateLimitPort`만 호출한다. Redis key, Lua, local counter와
`switch` 가 설정에서 구체 전략을 선택한다. 새 알고리즘 추가 = "새 `RateLimiter` 구현 + `RateLimitAlgorithm` provider 설정을 알지 못한다.
enum 값 + factory case" 이며 interceptor/web config 변경 불요. 향후 후보: `SLIDING_WINDOW`, `TOKEN_BUCKET`. - outbound provider activation SSOT는
- **알고리즘 중립 출력 계약**: 구현마다 카운트 방식이 달라도(fixed-window end vs 연속 sliding vs token refill) `ca-skeleton.capabilities.rate-limit.provider=disabled|redis`이고, HTTP enforcement의 별도 축은
`X-RateLimit-*` 헤더 계약이 안정적이도록 모든 구현이 `RateLimitDecision` 을 아래 의미로 채운다. `app.rate-limit.enabled`다. transport가 enabled인데 exact provider가 없거나 중복이면 startup을
- `limit` — 설정 quota 실패시킨다.
- `remaining` — 해당 키에 지금 아직 허용되는 요청 수, 0 으로 floor - fixed window, sliding counter, token bucket 선택과 policy revision은 Redis provider가 소유한다.
- `resetAt` — 키가 최소 1개 요청 capacity 를 다시 얻는 시각(fixed-window=window 종료, token-bucket=다음 과거 process-local unbounded fixed-window map/factory/settings는 제거되었다. local emergency가
refill, sliding-window=가장 오래된 카운트 요청 만료 시점) 필요하면 bounded cardinality/TTL/in-flight와 명시적 degraded-provider 계약을 먼저 추가해야 하며,
- `allowed` — quota 소진 시 false (→ 429) silent primary fallback은 허용하지 않는다.
- **FixedWindowRateLimiter 트레이드오프**: `X-RateLimit-Reset` 시각은 정확(window 종료)한 대신 window 경계를 - `EdgeRateLimitTransportBridge`는 provider의 typed allow/deny/unavailable/incompatible outcome을
가로지르는 burst 를 허용 — 스켈레톤 계약상 허용 가능. **D5**: 분산 limiter 는 core 범위 밖이라 per-instance HTTP 2xx/429/503과 `Retry-After`로만 투영한다. timeout은 quota가 소비되지 않았다는 증거가 아니다.
전용이며, 다중 인스턴스 배포 시 유효 한도는 설정값의 N배. key→window 맵은 evict 되지 않는다(single-node,
distinct active key 수로 bounded) — 키 cardinality 무제한 배포는 expiry/eviction 추가 필요.
### RateLimitKeyResolver ### RateLimitKeyResolver
- 키 형태: service-to-service - 키 형태: service-to-service
@@ -270,11 +283,12 @@ production configuration and compare the result with the committed snapshot.
- servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서 - servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서
resolve 되기 때문(RateLimitKeyResolver 참조). resolve 되기 때문(RateLimitKeyResolver 참조).
- `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest` - `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest`
슬라이스에서도 `RateLimitSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고 슬라이스에서도 `EdgeRateLimitTransportSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
슬라이스에선 `Clock#systemUTC()` 로 fallback. 슬라이스에선 `Clock#systemUTC()` 로 fallback.
### RateLimitInterceptor ### RateLimitInterceptor
- fixed-window rate limit 매핑된 handler 실행 전에 적용. 모든 응답에 `X-RateLimit-*` 헤더 포함(generated_if_missing=true). - provider가 선택한 rate-limit policy를 매핑된 handler 실행 전에 적용. quota 결과에는
`X-RateLimit-*` 헤더를 포함한다(generated_if_missing=true).
- 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable - 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable
의존성 장애로 오분류하는 것을 막는다. 의존성 장애로 오분류하는 것을 막는다.
@@ -295,12 +309,12 @@ production configuration and compare the result with the committed snapshot.
`Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동 `Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동
시점에 fail-fast 거부. 시점에 fail-fast 거부.
### RateLimitSettings ### EdgeRateLimitTransportSettings
- `ca-skeleton.rate-limit.*` 에서 바인딩되고, composition root 의 `@ConfigurationPropertiesScan` 으로 자동 등록된다.
- `enabled``APP_RATE_LIMIT_ENABLED`(env-keys.yaml, restart-only, behavior-change)에 매핑. - `app.rate-limit.*`은 HTTP enforcement, default policy ID, pseudonymization key version,
- `limit`/`window`/`algorithm` 은 env key 없음 — 리미터 튜닝 파라미터(`프로젝트 선택`; 멀티 인스턴스 caller deadline, trusted client-IP mode만 소유한다.
정확성은 범위 밖, D5)이며 fork 가 레지스트리 변경 없이 `application.yml` 에서 재정의하도록 in-code 기본값. - algorithm/quota/state TTL/HMAC secret는 outbound Redis capability 설정이 소유하며 web settings로
`algorithm` 기본값 `RateLimitAlgorithm.FIXED_WINDOW`. 복제하지 않는다.
### SecuritySettings ### SecuritySettings
- OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가 - OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가
+2
View File
@@ -6,6 +6,7 @@ dependencies {
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.springframework.session:spring-session-core'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
implementation('org.openapitools:jackson-databind-nullable:0.2.6') { implementation('org.openapitools:jackson-databind-nullable:0.2.6') {
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind' exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind'
@@ -15,4 +16,5 @@ dependencies {
// 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:3.0.0' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
testImplementation 'org.springframework.security:spring-security-test'
} }
+2
View File
@@ -164,7 +164,9 @@ org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runti
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -4,6 +4,7 @@ import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
@@ -24,6 +25,10 @@ import org.springframework.security.oauth2.jwt.SupplierJwtDecoder;
* README for the design rationale. * README for the design rationale.
*/ */
@Configuration @Configuration
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "jwt",
matchIfMissing = true)
public class JwtDecoderConfig { public class JwtDecoderConfig {
@Bean @Bean
@@ -0,0 +1,255 @@
package dev.caskeleton.adapter.inbound.web.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.SecurityContextRepository;
/**
* Stores only a bounded primitive authentication snapshot in {@link HttpSession}.
*
* <p>Spring Security objects, credentials, tokens and arbitrary principal graphs never cross the
* Spring Session serialization boundary.
*/
final class PrimitiveSessionSecurityContextRepository implements SecurityContextRepository {
static final String SNAPSHOT_ATTRIBUTE = "dev.caskeleton.security.PRIMITIVE_SECURITY_CONTEXT_V1";
private static final int MAGIC = 0x43534543;
private static final int VERSION = 1;
private static final int MAXIMUM_SNAPSHOT_BYTES = 16_384;
private static final int MAXIMUM_PRINCIPAL_BYTES = 256;
private static final int MAXIMUM_EMAIL_BYTES = 320;
private static final int MAXIMUM_TOKEN_BYTES = 128;
private static final int MAXIMUM_ROLES = 64;
private static final int MAXIMUM_AUTHORITIES = 128;
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
return load(requestResponseHolder.getRequest());
}
@Override
public void saveContext(
SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
Objects.requireNonNull(request, "request");
Authentication authentication = context == null ? null : context.getAuthentication();
if (authentication == null
|| !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
HttpSession existing = request.getSession(false);
if (existing != null) {
existing.removeAttribute(SNAPSHOT_ATTRIBUTE);
}
return;
}
request.getSession(true).setAttribute(SNAPSHOT_ATTRIBUTE, encode(authentication));
}
@Override
public boolean containsContext(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return session != null && session.getAttribute(SNAPSHOT_ATTRIBUTE) instanceof byte[];
}
private static SecurityContext load(HttpServletRequest request) {
SecurityContext empty = SecurityContextHolder.createEmptyContext();
HttpSession session = request.getSession(false);
if (session == null) {
return empty;
}
Object stored = session.getAttribute(SNAPSHOT_ATTRIBUTE);
if (!(stored instanceof byte[] snapshot)) {
return empty;
}
try {
PrimitiveAuthentication decoded = decode(snapshot);
AuthenticatedPrincipal principal =
new AuthenticatedPrincipal(decoded.principalId, decoded.email, decoded.roles);
List<GrantedAuthority> authorities =
decoded.authorities.stream()
.map(SimpleGrantedAuthority::new)
.map(GrantedAuthority.class::cast)
.toList();
empty.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities));
return empty;
} catch (IllegalArgumentException exception) {
session.removeAttribute(SNAPSHOT_ATTRIBUTE);
return empty;
}
}
private static byte[] encode(Authentication authentication) {
if (!(authentication.getPrincipal() instanceof AuthenticatedPrincipal principal)) {
throw new IllegalArgumentException(
"redis-session authentication requires an AuthenticatedPrincipal");
}
Set<String> roles = boundedTokens(principal.roles(), MAXIMUM_ROLES, "roles");
Set<String> authorities =
boundedTokens(
authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(),
MAXIMUM_AUTHORITIES,
"authorities");
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (DataOutputStream output = new DataOutputStream(bytes)) {
output.writeInt(MAGIC);
output.writeByte(VERSION);
writeText(output, principal.idpUserId(), MAXIMUM_PRINCIPAL_BYTES, "principal ID");
writeNullableText(output, principal.email(), MAXIMUM_EMAIL_BYTES, "email");
writeTokens(output, roles);
writeTokens(output, authorities);
}
byte[] snapshot = bytes.toByteArray();
if (snapshot.length > MAXIMUM_SNAPSHOT_BYTES) {
throw new IllegalArgumentException("security context snapshot exceeds the byte bound");
}
return snapshot;
} catch (IOException exception) {
throw new IllegalStateException("in-memory security context encoding failed", exception);
}
}
private static PrimitiveAuthentication decode(byte[] snapshot) {
if (snapshot.length < 1 || snapshot.length > MAXIMUM_SNAPSHOT_BYTES) {
throw invalidSnapshot();
}
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(snapshot.clone()))) {
if (input.readInt() != MAGIC || input.readUnsignedByte() != VERSION) {
throw invalidSnapshot();
}
String principalId = readText(input, MAXIMUM_PRINCIPAL_BYTES);
String email = readNullableText(input, MAXIMUM_EMAIL_BYTES);
Set<String> roles = readTokens(input, MAXIMUM_ROLES);
Set<String> authorities = readTokens(input, MAXIMUM_AUTHORITIES);
if (input.available() != 0) {
throw invalidSnapshot();
}
return new PrimitiveAuthentication(principalId, email, roles, authorities);
} catch (IOException | IllegalArgumentException exception) {
throw invalidSnapshot();
}
}
private static void writeTokens(DataOutputStream output, Set<String> values) throws IOException {
output.writeInt(values.size());
for (String value : values) {
writeText(output, value, MAXIMUM_TOKEN_BYTES, "security token");
}
}
private static Set<String> readTokens(DataInputStream input, int maximumCount)
throws IOException {
int count = input.readInt();
if (count < 0 || count > maximumCount) {
throw invalidSnapshot();
}
Set<String> values = new LinkedHashSet<>();
for (int index = 0; index < count; index++) {
if (!values.add(readText(input, MAXIMUM_TOKEN_BYTES))) {
throw invalidSnapshot();
}
}
return Set.copyOf(values);
}
private static Set<String> boundedTokens(
Collection<String> values, int maximumCount, String field) {
if (values == null || values.size() > maximumCount) {
throw new IllegalArgumentException(field + " exceed the configured count bound");
}
TreeSet<String> bounded = new TreeSet<>();
for (String value : values) {
requireBoundedText(value, MAXIMUM_TOKEN_BYTES, field);
bounded.add(value);
}
return Set.copyOf(bounded);
}
private static void writeNullableText(
DataOutputStream output, String value, int maximumBytes, String field) throws IOException {
output.writeBoolean(value != null);
if (value != null) {
writeText(output, value, maximumBytes, field);
}
}
private static String readNullableText(DataInputStream input, int maximumBytes)
throws IOException {
return input.readBoolean() ? readText(input, maximumBytes) : null;
}
private static void writeText(
DataOutputStream output, String value, int maximumBytes, String field) throws IOException {
byte[] encoded = requireBoundedText(value, maximumBytes, field);
output.writeInt(encoded.length);
output.write(encoded);
}
private static String readText(DataInputStream input, int maximumBytes) throws IOException {
int length = input.readInt();
if (length < 1 || length > maximumBytes || length > input.available()) {
throw new EOFException("invalid security context text length");
}
byte[] encoded = input.readNBytes(length);
String value = new String(encoded, StandardCharsets.UTF_8);
byte[] canonical = requireBoundedText(value, maximumBytes, "decoded value");
if (!java.util.Arrays.equals(canonical, encoded)) {
throw invalidSnapshot();
}
return value;
}
private static byte[] requireBoundedText(String value, int maximumBytes, String field) {
if (value == null || value.isBlank() || value.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(field + " must be non-blank text without controls");
}
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
if (encoded.length > maximumBytes) {
throw new IllegalArgumentException(field + " exceeds the UTF-8 byte bound");
}
return encoded;
}
private static IllegalArgumentException invalidSnapshot() {
return new IllegalArgumentException("security context snapshot is corrupt or incompatible");
}
private static final class PrimitiveAuthentication {
private final String principalId;
private final String email;
private final Set<String> roles;
private final Set<String> authorities;
private PrimitiveAuthentication(
String principalId, String email, Set<String> roles, Set<String> authorities) {
this.principalId = principalId;
this.email = email;
this.roles = roles;
this.authorities = authorities;
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.web.auth;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;
/** Provider-neutral servlet session filter and hardened host-only cookie composition. */
@Configuration(proxyBeanMethods = false)
@EnableSpringHttpSession
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "redis-session",
matchIfMissing = false)
public class RedisSessionWebConfig {
@Bean
CookieSerializer sessionCookieSerializer(SecuritySettings settings) {
SecuritySettings.SessionCookieSettings policy = settings.session();
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName(policy.cookieName());
serializer.setUseSecureCookie(policy.secure());
serializer.setUseHttpOnlyCookie(policy.httpOnly());
serializer.setSameSite(policy.sameSite());
serializer.setCookiePath(policy.path());
serializer.setCookieMaxAge(-1);
serializer.setUseBase64Encoding(true);
// No domain or domain pattern is configured: the session cookie remains host-only.
return serializer;
}
}
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.inbound.web.auth;
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings; import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -10,6 +11,8 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@@ -49,19 +52,28 @@ public class SecurityConfig {
return new EnvelopeAccessDeniedHandler(classifier, objectMapper); return new EnvelopeAccessDeniedHandler(classifier, objectMapper);
} }
@Bean
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "redis-session",
matchIfMissing = false)
PrimitiveSessionSecurityContextRepository primitiveSessionSecurityContextRepository() {
return new PrimitiveSessionSecurityContextRepository();
}
@Bean @Bean
public SecurityFilterChain filterChain( public SecurityFilterChain filterChain(
HttpSecurity http, HttpSecurity http,
AuthenticationEntryPoint authenticationEntryPoint, AuthenticationEntryPoint authenticationEntryPoint,
AccessDeniedHandler accessDeniedHandler) AccessDeniedHandler accessDeniedHandler,
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
sessionSecurityContextRepository)
throws Exception { throws Exception {
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]); String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
http.csrf(csrf -> csrf.disable()) http.cors(c -> c.configurationSource(corsConfigurationSource()))
.cors(c -> c.configurationSource(corsConfigurationSource()))
// Disable Spring Security's default Cache-Control writer; CacheControlFilter // Disable Spring Security's default Cache-Control writer; CacheControlFilter
// owns the cache header policy. See README for the design rationale. // owns the cache header policy. See README for the design rationale.
.headers(headers -> headers.cacheControl(cache -> cache.disable())) .headers(headers -> headers.cacheControl(cache -> cache.disable()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests( .authorizeHttpRequests(
auth -> { auth -> {
if (publicPaths.length > 0) { if (publicPaths.length > 0) {
@@ -75,13 +87,45 @@ public class SecurityConfig {
.exceptionHandling( .exceptionHandling(
ex -> ex ->
ex.authenticationEntryPoint(authenticationEntryPoint) ex.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)) .accessDeniedHandler(accessDeniedHandler));
.oauth2ResourceServer( if (securitySettings.authMode() == SecuritySettings.AuthenticationMode.JWT) {
oauth -> http.csrf(csrf -> csrf.disable())
oauth .sessionManagement(
.authenticationEntryPoint(authenticationEntryPoint) session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.accessDeniedHandler(accessDeniedHandler) .oauth2ResourceServer(
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter))); oauth ->
oauth
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
} else {
SecuritySettings.SessionCookieSettings sessionSettings = securitySettings.session();
CookieCsrfTokenRepository csrfRepository = new CookieCsrfTokenRepository();
csrfRepository.setCookieName(sessionSettings.csrfCookieName());
csrfRepository.setHeaderName(sessionSettings.csrfHeaderName());
csrfRepository.setCookieCustomizer(
cookie ->
cookie
.secure(true)
.httpOnly(false)
.sameSite(sessionSettings.sameSite())
.path(sessionSettings.path()));
CsrfTokenRequestAttributeHandler csrfRequestHandler = new CsrfTokenRequestAttributeHandler();
http.csrf(
csrf ->
csrf.csrfTokenRepository(csrfRepository)
.csrfTokenRequestHandler(csrfRequestHandler))
.sessionManagement(
session ->
session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation(fixation -> fixation.migrateSession()))
.securityContext(
securityContext ->
securityContext
.securityContextRepository(sessionSecurityContextRepository.getObject())
.requireExplicitSave(false));
}
return http.build(); return http.build();
} }
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Provider-neutral bridge from an HTTP request to {@link EdgeRateLimitPort}.
*
* <p>Raw principal, API-key identity, client IP, and route values stop at the pseudonymizer. Only
* the versioned digest and bounded enforcement metadata cross the provider boundary.
*/
public final class EdgeRateLimitTransportBridge {
private static final Pattern POLICY_ID = Pattern.compile("[a-z][a-z0-9-]{0,62}");
private static final Duration MAXIMUM_CALLER_DEADLINE_BUDGET = Duration.ofSeconds(30);
private final EdgeRateLimitPort port;
private final EdgeSubjectPseudonymizer pseudonymizer;
private final RateLimitKeyResolver subjectResolver;
private final Clock clock;
private final String policyId;
private final Duration callerDeadlineBudget;
private final RateLimitEvaluationIdGenerator evaluationIdGenerator;
public EdgeRateLimitTransportBridge(
EdgeRateLimitPort port,
EdgeSubjectPseudonymizer pseudonymizer,
RateLimitKeyResolver subjectResolver,
Clock clock,
String policyId,
Duration callerDeadlineBudget,
RateLimitEvaluationIdGenerator evaluationIdGenerator) {
this.port = Objects.requireNonNull(port, "port must not be null");
this.pseudonymizer = Objects.requireNonNull(pseudonymizer, "pseudonymizer must not be null");
this.subjectResolver =
Objects.requireNonNull(subjectResolver, "subjectResolver must not be null");
this.clock = Objects.requireNonNull(clock, "clock must not be null");
if (policyId == null || !POLICY_ID.matcher(policyId).matches()) {
throw new IllegalArgumentException("policyId must be a bounded policy identifier");
}
this.policyId = policyId;
this.callerDeadlineBudget = positiveBoundedBudget(callerDeadlineBudget, "callerDeadlineBudget");
this.evaluationIdGenerator =
Objects.requireNonNull(evaluationIdGenerator, "evaluationIdGenerator must not be null");
}
public RateLimitOutcome evaluate(HttpServletRequest request) {
Objects.requireNonNull(request, "request must not be null");
EdgeRateLimitSubject rawSubject = subjectResolver.resolve(request);
RateLimitSubjectDigest subjectDigest =
Objects.requireNonNull(
pseudonymizer.pseudonymize(rawSubject), "pseudonymizer must return a subject digest");
Instant callerDeadline = clock.instant().plus(callerDeadlineBudget);
String evaluationId =
Objects.requireNonNull(
evaluationIdGenerator.generate(), "evaluationIdGenerator must return an evaluation ID");
return Objects.requireNonNull(
port.evaluate(
new RateLimitRequest(policyId, subjectDigest, 1, evaluationId, callerDeadline)),
"rate-limit port must return an outcome");
}
static Duration positiveBoundedBudget(Duration value, String field) {
Objects.requireNonNull(value, field + " must not be null");
if (value.isZero()
|| value.isNegative()
|| value.compareTo(MAXIMUM_CALLER_DEADLINE_BUDGET) > 0) {
throw new IllegalArgumentException(field + " must be positive and no more than 30 seconds");
}
long milliseconds = value.toMillis();
if (!Duration.ofMillis(milliseconds).equals(value)) {
throw new IllegalArgumentException(field + " must use whole milliseconds");
}
return value;
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Duration;
import java.util.regex.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* HTTP bridge settings bound to the transport-only {@code app.rate-limit} axis.
*
* <p>The outbound provider is selected independently by {@code
* ca-skeleton.capabilities.rate-limit.provider}; enabling this bridge never selects a provider or a
* fallback.
*/
@ConfigurationProperties(prefix = "app.rate-limit")
public record EdgeRateLimitTransportSettings(
boolean enabled,
String defaultPolicyId,
Duration callerDeadlineBudget,
int hashKeyVersion,
RateLimitClientIpMode clientIpMode) {
private static final Pattern POLICY_ID = Pattern.compile("[a-z][a-z0-9-]{0,62}");
public EdgeRateLimitTransportSettings {
defaultPolicyId =
defaultPolicyId == null || defaultPolicyId.isBlank() ? "api-default" : defaultPolicyId;
callerDeadlineBudget =
callerDeadlineBudget == null ? Duration.ofSeconds(2) : callerDeadlineBudget;
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
clientIpMode = clientIpMode == null ? RateLimitClientIpMode.REMOTE_ADDR_ONLY : clientIpMode;
if (!POLICY_ID.matcher(defaultPolicyId).matches()) {
throw new IllegalArgumentException("defaultPolicyId must be a bounded policy identifier");
}
EdgeRateLimitTransportBridge.positiveBoundedBudget(
callerDeadlineBudget, "callerDeadlineBudget");
if (hashKeyVersion < 1 || hashKeyVersion > 9999) {
throw new IllegalArgumentException("hashKeyVersion must be in 1..9999");
}
}
}
@@ -1,54 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Single-node, in-process fixed-window rate limiter. Each key gets a counter for the current window
* {@code floor(epochSecond / window)}; the counter resets when the window rolls. See README for the
* design rationale.
*/
public final class FixedWindowRateLimiter implements RateLimiter {
private final int limit;
private final long windowSeconds;
private final Clock clock;
private final ConcurrentMap<String, Window> windows = new ConcurrentHashMap<>();
public FixedWindowRateLimiter(int limit, Duration window, Clock clock) {
this.limit = Math.max(1, limit);
this.windowSeconds = Math.max(1L, window.toSeconds());
this.clock = clock;
}
@Override
public RateLimitDecision decide(String key) {
long nowSecond = clock.instant().getEpochSecond();
long windowId = nowSecond / windowSeconds;
Instant resetAt = Instant.ofEpochSecond((windowId + 1) * windowSeconds);
Window window =
windows.compute(
key,
(k, current) ->
(current == null || current.id != windowId) ? new Window(windowId) : current);
int count = window.count.incrementAndGet();
boolean allowed = count <= limit;
int remaining = Math.max(0, limit - count);
return new RateLimitDecision(allowed, limit, remaining, resetAt);
}
private static final class Window {
private final long id;
private final AtomicInteger count = new AtomicInteger();
private Window(long id) {
this.id = id;
}
}
}
@@ -1,11 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
/**
* Selectable rate-limit algorithm, bound from {@code ca-skeleton.rate-limit.algorithm}. See README
* for the design rationale.
*/
public enum RateLimitAlgorithm {
/** Fixed-window counter — the default single-node implementation. */
FIXED_WINDOW
}
@@ -1,15 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Instant;
/**
* Outcome of a single rate-limit check, carrying the values surfaced as the {@code X-RateLimit-*}
* signaling headers. See README for the design rationale.
*
* @param allowed false when the caller has exceeded the limit this window ( 429)
* @param limit the window quota ({@code X-RateLimit-Limit})
* @param remaining requests left in the current window, floored at 0 ({@code
* X-RateLimit-Remaining})
* @param resetAt instant the current fixed window ends ({@code X-RateLimit-Reset}, rfc3339)
*/
public record RateLimitDecision(boolean allowed, int limit, int remaining, Instant resetAt) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
/** Server-owned source of per-evaluation replay identifiers. */
@FunctionalInterface
public interface RateLimitEvaluationIdGenerator {
String generate();
}
@@ -2,73 +2,111 @@ package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.OperationalError; import dev.caskeleton.shared.error.OperationalError;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.response.Envelope; import dev.caskeleton.shared.response.Envelope;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Objects;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerInterceptor;
import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
/** /**
* Applies the rate limit before a mapped handler runs. Every response carries the {@code * Maps provider-neutral rate-limit outcomes to the stable HTTP signaling contract.
* X-RateLimit-*} signaling headers; when the limit is exceeded the request is rejected with a 429 *
* {@code RATE_LIMIT_EXCEEDED} envelope, a {@code Retry-After} header, and the signaling headers. * <p>Disabled instances have no bridge and therefore cannot resolve a subject, pseudonymize, or
* See README for the design rationale. * invoke a provider.
*/ */
public final class RateLimitInterceptor implements HandlerInterceptor { public final class RateLimitInterceptor implements HandlerInterceptor {
private static final String CLIENT_SAFE_MESSAGE = private static final String DENIED_MESSAGE =
"Too many requests, please retry after the indicated interval"; "Too many requests, please retry after the indicated interval";
private static final String UNAVAILABLE_MESSAGE =
"Rate-limit enforcement is temporarily unavailable";
private static final String INCOMPATIBLE_MESSAGE =
"Rate-limit enforcement is unavailable due to an incompatible provider";
private final boolean enabled; private final EdgeRateLimitTransportBridge bridge;
private final RateLimiter limiter;
private final RateLimitKeyResolver keyResolver;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final int retryAfterSeconds;
public RateLimitInterceptor( private RateLimitInterceptor(EdgeRateLimitTransportBridge bridge, ObjectMapper objectMapper) {
boolean enabled, this.bridge = bridge;
RateLimiter limiter, this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
RateLimitKeyResolver keyResolver, }
ObjectMapper objectMapper,
int retryAfterSeconds) { public static RateLimitInterceptor enabled(
this.enabled = enabled; EdgeRateLimitTransportBridge bridge, ObjectMapper objectMapper) {
this.limiter = limiter; return new RateLimitInterceptor(
this.keyResolver = keyResolver; Objects.requireNonNull(bridge, "bridge must not be null"), objectMapper);
this.objectMapper = objectMapper; }
this.retryAfterSeconds = retryAfterSeconds;
public static RateLimitInterceptor disabled(ObjectMapper objectMapper) {
return new RateLimitInterceptor(null, objectMapper);
} }
@Override @Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception { throws Exception {
if (!enabled) { if (bridge == null) {
return true; return true;
} }
RateLimitDecision decision = limiter.decide(keyResolver.resolve(request)); return switch (bridge.evaluate(request)) {
case RateLimitOutcome.Evaluated evaluated -> handleEvaluated(response, evaluated.decision());
case RateLimitOutcome.Unavailable unavailable ->
rejectUnavailable(response, unavailable.retryAfter());
case RateLimitOutcome.Indeterminate indeterminate ->
rejectUnavailable(response, indeterminate.retryAfter());
case RateLimitOutcome.Incompatible incompatible -> rejectIncompatible(response);
};
}
private boolean handleEvaluated(HttpServletResponse response, RateLimitDecision decision)
throws Exception {
applySignalingHeaders(response, decision); applySignalingHeaders(response, decision);
if (decision.allowed()) { if (decision.allowed()) {
return true; return true;
} }
rejectWith429(response); response.setHeader(ApiHeaders.RETRY_AFTER, retryAfterSeconds(decision.retryAfter()));
reject(response, OperationalError.RATE_LIMIT_EXCEEDED, DENIED_MESSAGE);
return false; return false;
} }
private void applySignalingHeaders(HttpServletResponse response, RateLimitDecision decision) { private boolean rejectUnavailable(HttpServletResponse response, Duration retryAfter)
response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Integer.toString(decision.limit())); throws Exception {
response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Integer.toString(decision.remaining())); response.setHeader(ApiHeaders.RETRY_AFTER, retryAfterSeconds(retryAfter));
reject(response, RateLimitTransportError.RATE_LIMIT_UNAVAILABLE, UNAVAILABLE_MESSAGE);
return false;
}
private boolean rejectIncompatible(HttpServletResponse response) throws Exception {
reject(response, RateLimitTransportError.RATE_LIMIT_INCOMPATIBLE, INCOMPATIBLE_MESSAGE);
return false;
}
private static void applySignalingHeaders(
HttpServletResponse response, RateLimitDecision decision) {
response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Long.toString(decision.limit()));
response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Long.toString(decision.remaining()));
response.setHeader( response.setHeader(
ApiHeaders.X_RATELIMIT_RESET, DateTimeFormatter.ISO_INSTANT.format(decision.resetAt())); ApiHeaders.X_RATELIMIT_RESET, DateTimeFormatter.ISO_INSTANT.format(decision.resetAt()));
} }
private void rejectWith429(HttpServletResponse response) throws Exception { private void reject(HttpServletResponse response, ApiErrorCode error, String message)
response.setStatus(OperationalError.RATE_LIMIT_EXCEEDED.httpStatus()); throws Exception {
response.setHeader(ApiHeaders.RETRY_AFTER, Integer.toString(retryAfterSeconds)); response.setStatus(error.httpStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Envelope<Void> body = Envelope<Void> body = ErrorResponseFactory.body(error, message, null);
ErrorResponseFactory.body(OperationalError.RATE_LIMIT_EXCEEDED, CLIENT_SAFE_MESSAGE, null);
objectMapper.writeValue(response.getWriter(), body); objectMapper.writeValue(response.getWriter(), body);
} }
private static String retryAfterSeconds(Duration retryAfter) {
long milliseconds = retryAfter.toMillis();
long seconds = Math.floorDiv(milliseconds + 999, 1000);
return Long.toString(seconds);
}
} }
@@ -1,21 +1,24 @@
package dev.caskeleton.adapter.inbound.web.ratelimit; package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.util.Locale;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.HandlerMapping;
/** /**
* Derives the rate-limit key from a request: * Derives a bounded pre-pseudonymization rate-limit subject from a request:
* *
* <ul> * <ul>
* <li>authenticated user {@code user:<principal>} * <li>authenticated user principal + operation
* <li>service-to-service (a {@code service}-role principal) {@code apikey:<id>} * <li>service-to-service (a {@code service}-role principal) API key + operation
* <li>unauthenticated {@code ip:<source-ip>:<METHOD route-template>} * <li>unauthenticated client IP + operation
* </ul> * </ul>
* *
* <p>See README for the design rationale. * <p>The returned raw identity exists only until {@link EdgeSubjectPseudonymizer} runs. It must not
* cross the provider port boundary.
*/ */
public final class RateLimitKeyResolver { public final class RateLimitKeyResolver {
@@ -27,19 +30,37 @@ public final class RateLimitKeyResolver {
this.clientIpResolver = clientIpResolver; this.clientIpResolver = clientIpResolver;
} }
public String resolve(HttpServletRequest request) { public EdgeRateLimitSubject resolve(HttpServletRequest request) {
String operationId = operationId(request);
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null if (auth != null
&& auth.isAuthenticated() && auth.isAuthenticated()
&& auth.getPrincipal() instanceof AuthenticatedPrincipal user) { && auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
return user.hasRole(SERVICE_ROLE) ? "apikey:" + user.idpUserId() : "user:" + user.idpUserId(); EdgeRateLimitSubject.Kind kind =
user.hasRole(SERVICE_ROLE)
? EdgeRateLimitSubject.Kind.API_KEY
: EdgeRateLimitSubject.Kind.PRINCIPAL;
return new EdgeRateLimitSubject(kind, user.idpUserId(), operationId);
} }
return "ip:" + clientIpResolver.resolve(request) + ":" + routeTemplate(request); return new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.CLIENT_IP, clientIpResolver.resolve(request), operationId);
} }
private static String routeTemplate(HttpServletRequest request) { private static String operationId(HttpServletRequest request) {
String method = normalizedMethod(request.getMethod());
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
String route = pattern instanceof String s ? s : request.getRequestURI(); String route =
return request.getMethod() + " " + route; pattern instanceof String value && !value.isBlank() ? value : "<unresolved-route>";
return method + " " + route;
}
private static String normalizedMethod(String method) {
if (method == null
|| method.isBlank()
|| method.length() > 16
|| !method.chars().allMatch(Character::isLetter)) {
return "OTHER";
}
return method.toUpperCase(Locale.ROOT);
} }
} }
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
/** HTTP-only mapping codes for provider outcomes that do not contain an allow/deny decision. */
enum RateLimitTransportError implements ApiErrorCode {
RATE_LIMIT_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, true),
RATE_LIMIT_INCOMPATIBLE(Category.INTERNAL, false);
private final Category category;
private final boolean retryable;
RateLimitTransportError(Category category, boolean retryable) {
this.category = category;
this.retryable = retryable;
}
@Override
public String code() {
return name();
}
@Override
public Category category() {
return category;
}
@Override
public int httpStatus() {
return 503;
}
@Override
public boolean retryable() {
return retryable;
}
}
@@ -1,8 +1,7 @@
package dev.caskeleton.adapter.inbound.web.ratelimit; package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor; import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import dev.caskeleton.adapter.inbound.web.settings.RateLimitSettings; import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import dev.caskeleton.shared.error.OperationalError;
import java.time.Clock; import java.time.Clock;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -12,39 +11,57 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
/** /**
* Wires the {@link RateLimitInterceptor} into the MVC interceptor chain. The {@link Clock} is taken * Wires provider-neutral edge enforcement into MVC.
* from the shared application bean when present and falls back to {@link Clock#systemUTC()}. With *
* no rate-limit config bound, {@code enabled} defaults to {@code false} and the interceptor is a * <p>Transport activation, trusted client-IP selection, policy selection, and deadlines come from
* pass-through. See README for the design rationale. * {@code app.rate-limit}. Provider activation is a separate composition-root decision; an enabled
* bridge requires exactly one semantic port and never installs a local fallback.
*/ */
@Configuration @Configuration
@EnableConfigurationProperties(RateLimitSettings.class) @EnableConfigurationProperties(EdgeRateLimitTransportSettings.class)
public class RateLimitWebConfig implements WebMvcConfigurer { public class RateLimitWebConfig implements WebMvcConfigurer {
private final RateLimitInterceptor rateLimitInterceptor; private final RateLimitInterceptor rateLimitInterceptor;
public RateLimitWebConfig( public RateLimitWebConfig(
RateLimitSettings properties, ObjectMapper objectMapper, ObjectProvider<Clock> clock) { EdgeRateLimitTransportSettings transportSettings,
RateLimiter limiter = ObjectMapper objectMapper,
RateLimiterFactory.create( ObjectProvider<Clock> clockProvider,
properties.algorithm(), ObjectProvider<EdgeRateLimitPort> portProvider,
properties.limit(), ObjectProvider<UserPrincipalPseudonymizerPort> pseudonymizerProvider) {
properties.window(), if (!transportSettings.enabled()) {
clock.getIfAvailable(Clock::systemUTC)); this.rateLimitInterceptor = RateLimitInterceptor.disabled(objectMapper);
int retryAfter = return;
RetryAfterAdvisor.retryAfterSeconds(OperationalError.RATE_LIMIT_EXCEEDED).orElse(1); }
ClientIpResolver clientIpResolver = ClientIpResolverFactory.create(properties.clientIpMode());
this.rateLimitInterceptor = EdgeRateLimitPort port = requiredUnique(portProvider, "EdgeRateLimitPort");
new RateLimitInterceptor( UserPrincipalPseudonymizerPort secretBackedPseudonymizer =
properties.enabled(), requiredUnique(pseudonymizerProvider, "UserPrincipalPseudonymizerPort");
limiter, EdgeRateLimitTransportBridge bridge =
new RateLimitKeyResolver(clientIpResolver), new EdgeRateLimitTransportBridge(
objectMapper, port,
retryAfter); new VersionedEdgeSubjectPseudonymizer(
secretBackedPseudonymizer, transportSettings.hashKeyVersion()),
new RateLimitKeyResolver(
ClientIpResolverFactory.create(transportSettings.clientIpMode())),
clockProvider.getIfAvailable(Clock::systemUTC),
transportSettings.defaultPolicyId(),
transportSettings.callerDeadlineBudget(),
SecureRandomRateLimitEvaluationIdGenerator.versionOne());
this.rateLimitInterceptor = RateLimitInterceptor.enabled(bridge, objectMapper);
} }
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor); registry.addInterceptor(rateLimitInterceptor);
} }
private static <T> T requiredUnique(ObjectProvider<T> provider, String capability) {
T instance = provider.getIfUnique();
if (instance == null) {
throw new IllegalStateException(
capability + " must have exactly one bean when edge rate limiting is enabled");
}
return instance;
}
} }
@@ -1,12 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
/**
* Rate-limit strategy. Implementations populate {@link RateLimitDecision} so the {@code
* X-RateLimit-*} header contract stays stable across a strategy swap. See README for the design
* rationale and the algorithm-neutral output contract.
*/
public interface RateLimiter {
/** Register one request for {@code key} and report whether it is within the limit. */
RateLimitDecision decide(String key);
}
@@ -1,17 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Clock;
import java.time.Duration;
/** Builds the configured {@link RateLimiter} strategy. See README for the design rationale. */
public final class RateLimiterFactory {
private RateLimiterFactory() {}
public static RateLimiter create(
RateLimitAlgorithm algorithm, int limit, Duration window, Clock clock) {
return switch (algorithm) {
case FIXED_WINDOW -> new FixedWindowRateLimiter(limit, window, clock);
};
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Objects;
/**
* Cryptographically random evaluation ID generator.
*
* <p>IDs are created only by the server. HTTP headers and request bodies are never consulted.
*/
public final class SecureRandomRateLimitEvaluationIdGenerator
implements RateLimitEvaluationIdGenerator {
private static final int RANDOM_BYTES = 16;
private final SecureRandom secureRandom;
private final String prefix;
public SecureRandomRateLimitEvaluationIdGenerator(SecureRandom secureRandom, int version) {
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom must not be null");
if (version < 1 || version > 9999) {
throw new IllegalArgumentException("evaluation ID version must be in 1..9999");
}
this.prefix = "ev" + version + ":";
}
public static SecureRandomRateLimitEvaluationIdGenerator versionOne() {
return new SecureRandomRateLimitEvaluationIdGenerator(new SecureRandom(), 1);
}
@Override
public String generate() {
byte[] random = new byte[RANDOM_BYTES];
secureRandom.nextBytes(random);
return prefix + Base64.getUrlEncoder().withoutPadding().encodeToString(random);
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* Adapts the application-provided secret-backed HMAC capability to the edge subject contract.
*
* <p>The adapter length-frames each dimension before hashing and adds an explicit key-rotation
* version to the resulting digest. It does not resolve or retain the HMAC secret.
*/
final class VersionedEdgeSubjectPseudonymizer implements EdgeSubjectPseudonymizer {
private final UserPrincipalPseudonymizerPort delegate;
private final int version;
VersionedEdgeSubjectPseudonymizer(UserPrincipalPseudonymizerPort delegate, int version) {
this.delegate = Objects.requireNonNull(delegate, "delegate must not be null");
if (version < 1 || version > 9999) {
throw new IllegalArgumentException("subject digest version must be in 1..9999");
}
this.version = version;
}
@Override
public RateLimitSubjectDigest pseudonymize(EdgeRateLimitSubject subject) {
Objects.requireNonNull(subject, "subject must not be null");
String canonical =
frame(subject.kind().name())
+ "|"
+ frame(subject.canonicalIdentity())
+ "|"
+ frame(subject.operationId());
String digest = delegate.pseudonymize(canonical);
return new RateLimitSubjectDigest("v" + version + ":" + digest);
}
private static String frame(String value) {
return value.getBytes(StandardCharsets.UTF_8).length + ":" + value;
}
}
@@ -1,42 +0,0 @@
package dev.caskeleton.adapter.inbound.web.settings;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Rate-limit knobs bound from {@code ca-skeleton.rate-limit.*}. See README for the design
* rationale.
*
* @param enabled whether the rate-limit interceptor enforces limits
* @param limit max requests allowed per key within one window
* @param window the fixed time window over which {@code limit} is counted
* @param algorithm the rate-limit strategy to use
* @param clientIpMode client-IP source for unauthenticated rate-limit keys
*/
@Validated
@ConfigurationProperties(prefix = "ca-skeleton.rate-limit")
public record RateLimitSettings(
boolean enabled,
Integer limit,
Duration window,
RateLimitAlgorithm algorithm,
RateLimitClientIpMode clientIpMode) {
public RateLimitSettings {
if (limit == null || limit < 1) {
limit = 100;
}
if (window == null || window.isZero() || window.isNegative()) {
window = Duration.ofSeconds(1);
}
if (algorithm == null) {
algorithm = RateLimitAlgorithm.FIXED_WINDOW;
}
if (clientIpMode == null) {
clientIpMode = RateLimitClientIpMode.REMOTE_ADDR_ONLY;
}
}
}
@@ -4,29 +4,107 @@ import java.util.List;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/** /**
* OIDC resource-server config bound from {@code ca-skeleton.security.*}. See README for the design * Exclusive JWT or Redis-backed browser-session security policy bound from {@code
* rationale. * ca-skeleton.security.*}.
*/ */
@ConfigurationProperties(prefix = "ca-skeleton.security") @ConfigurationProperties(prefix = "ca-skeleton.security")
public record SecuritySettings(String issuerUri, String audience, List<String> publicPaths) { public record SecuritySettings(
AuthenticationMode authMode,
String issuerUri,
String audience,
List<String> publicPaths,
SessionCookieSettings session) {
private static final Logger log = LoggerFactory.getLogger(SecuritySettings.class); private static final Logger log = LoggerFactory.getLogger(SecuritySettings.class);
public SecuritySettings { @ConstructorBinding
if (issuerUri == null || issuerUri.isBlank()) { public SecuritySettings(
AuthenticationMode authMode,
String issuerUri,
String audience,
List<String> publicPaths,
SessionCookieSettings session) {
this.authMode = authMode == null ? AuthenticationMode.JWT : authMode;
if (this.authMode == AuthenticationMode.JWT && (issuerUri == null || issuerUri.isBlank())) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"APP_SECURITY_JWT_ISSUER (ca-skeleton.security.issuer-uri) is required"); "APP_SECURITY_JWT_ISSUER (ca-skeleton.security.issuer-uri) is required");
} }
this.issuerUri = issuerUri == null ? "" : issuerUri.trim();
if (audience == null) { if (audience == null) {
log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation"); if (this.authMode == AuthenticationMode.JWT) {
audience = ""; log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation");
}
this.audience = "";
} else {
this.audience = audience.trim();
} }
if (publicPaths == null) { if (publicPaths == null) {
publicPaths = List.of(); this.publicPaths = List.of();
} else { } else {
publicPaths = List.copyOf(publicPaths); this.publicPaths = List.copyOf(publicPaths);
}
this.session = session == null ? SessionCookieSettings.defaults() : session;
}
public SecuritySettings(String issuerUri, String audience, List<String> publicPaths) {
this(AuthenticationMode.JWT, issuerUri, audience, publicPaths, null);
}
public enum AuthenticationMode {
JWT,
REDIS_SESSION
}
public record SessionCookieSettings(
String cookieName,
Boolean secure,
Boolean httpOnly,
String sameSite,
String path,
String csrfCookieName,
String csrfHeaderName) {
public SessionCookieSettings(
String cookieName,
Boolean secure,
Boolean httpOnly,
String sameSite,
String path,
String csrfCookieName,
String csrfHeaderName) {
this.cookieName = safeName(cookieName, "CA_SESSION", "cookieName");
this.secure = secure == null || secure;
this.httpOnly = httpOnly == null || httpOnly;
this.sameSite = sameSite == null || sameSite.isBlank() ? "Lax" : sameSite;
if (!this.sameSite.matches("Lax|Strict|None")) {
throw new IllegalArgumentException("session sameSite must be Lax, Strict, or None");
}
this.path = path == null || path.isBlank() ? "/" : path;
if (!this.path.startsWith("/")
|| this.path.length() > 128
|| this.path.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException("session cookie path must be a bounded absolute path");
}
this.csrfCookieName = safeName(csrfCookieName, "XSRF-TOKEN", "csrfCookieName");
this.csrfHeaderName = safeName(csrfHeaderName, "X-XSRF-TOKEN", "csrfHeaderName");
if (!this.secure || !this.httpOnly) {
throw new IllegalArgumentException("Redis session cookie must remain Secure and HttpOnly");
}
}
private static SessionCookieSettings defaults() {
return new SessionCookieSettings(null, null, null, null, null, null, null);
}
private static String safeName(String value, String fallback, String field) {
String resolved = value == null || value.isBlank() ? fallback : value;
if (!resolved.matches("[A-Za-z][A-Za-z0-9_-]{1,63}")) {
throw new IllegalArgumentException(field + " must be a bounded cookie/header token");
}
return resolved;
} }
} }
} }
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Set;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpRequestResponseHolder;
class PrimitiveSessionSecurityContextRepositoryTest {
private final PrimitiveSessionSecurityContextRepository repository =
new PrimitiveSessionSecurityContextRepository();
@Test
void roundTripsOnlyABoundedPrimitiveSnapshotWithoutCredentialsOrFrameworkObjects() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(
"idp-user-42", "user@example.test", Set.of("operator", "auditor")),
"must-never-be-stored",
Set.of(
new SimpleGrantedAuthority("ROLE_OPERATOR"),
new SimpleGrantedAuthority("worklog:read"))));
repository.saveContext(context, request, response);
Object stored =
request
.getSession(false)
.getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE);
assertThat(stored).isInstanceOf(byte[].class);
assertThat(request.getSession(false).getAttribute("SPRING_SECURITY_CONTEXT")).isNull();
var loaded =
repository
.loadContext(new HttpRequestResponseHolder(request, response))
.getAuthentication();
assertThat(loaded.getCredentials()).isNull();
assertThat(loaded.getPrincipal())
.isEqualTo(
new AuthenticatedPrincipal(
"idp-user-42", "user@example.test", Set.of("operator", "auditor")));
assertThat(loaded.getAuthorities())
.extracting(authority -> authority.getAuthority())
.containsExactlyInAnyOrder("ROLE_OPERATOR", "worklog:read");
}
@Test
void rejectsForeignPrincipalGraphsAndFailsClosedOnCorruptSnapshots() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
var foreign = SecurityContextHolder.createEmptyContext();
foreign.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(new Object(), "credential", Set.of()));
assertThatThrownBy(() -> repository.saveContext(foreign, request, response))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("AuthenticatedPrincipal");
request
.getSession(true)
.setAttribute(
PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE,
new byte[] {0x01, 0x02, 0x03});
assertThat(
repository
.loadContext(new HttpRequestResponseHolder(request, response))
.getAuthentication())
.isNull();
assertThat(
request
.getSession(false)
.getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE))
.isNull();
}
@Test
void rejectsAuthorityCountsBeyondThePublishedBound() {
MockHttpServletRequest request = new MockHttpServletRequest();
var authorities =
IntStream.range(0, 129)
.mapToObj(index -> new SimpleGrantedAuthority("authority-" + index))
.toList();
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal("idp-user-42", null, Set.of()), null, authorities));
assertThatThrownBy(
() -> repository.saveContext(context, request, new MockHttpServletResponse()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("authorities");
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.session.MapSessionRepository;
import org.springframework.session.web.http.CookieSerializer;
class RedisSessionWebConfigTest {
private final WebApplicationContextRunner runner =
new WebApplicationContextRunner()
.withUserConfiguration(PropertiesConfig.class, RedisSessionWebConfig.class);
@Test
void jwtModeCreatesNoSessionFilterOrCookieSerializer() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=jwt",
"ca-skeleton.security.issuer-uri=https://issuer.example")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean(CookieSerializer.class);
assertThat(context).doesNotHaveBean("springSessionRepositoryFilter");
});
}
@Test
void redisSessionModeWritesSecureHttpOnlySameSiteHostOnlyCookie() {
runner
.withBean(
MapSessionRepository.class, () -> new MapSessionRepository(new ConcurrentHashMap<>()))
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.session.cookie-name=APP_SESSION",
"ca-skeleton.security.session.secure=true",
"ca-skeleton.security.session.http-only=true",
"ca-skeleton.security.session.same-site=Strict",
"ca-skeleton.security.session.path=/")
.run(
context -> {
assertThat(context).hasNotFailed();
CookieSerializer serializer = context.getBean(CookieSerializer.class);
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSecure(true);
MockHttpServletResponse response = new MockHttpServletResponse();
serializer.writeCookieValue(
new CookieSerializer.CookieValue(request, response, "opaque-session-id"));
assertThat(response.getHeader("Set-Cookie"))
.contains("APP_SESSION=")
.contains("Path=/")
.contains("Secure")
.contains("HttpOnly")
.contains("SameSite=Strict")
.doesNotContain("Domain=");
});
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(SecuritySettings.class)
static class PropertiesConfig {}
}
@@ -0,0 +1,199 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import jakarta.servlet.Filter;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.session.SessionManagementFilter;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.ObjectMapper;
class SecurityModeWebContractTest {
private final WebApplicationContextRunner runner =
new WebApplicationContextRunner()
.withUserConfiguration(PropertiesConfig.class, SecurityConfig.class)
.withBean(JwtToAuthenticatedPrincipalConverter.class)
.withBean(ObjectMapper.class, ObjectMapper::new)
.withBean(
JwtDecoder.class,
() ->
token -> {
throw new UnsupportedOperationException("decoder must remain unused");
});
@Test
void redisSessionModeEnablesCsrfAndRotatesAnAuthenticatedSessionIdentifier() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.public-paths=/probe,/csrf",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class));
try {
mvc.perform(post("/probe")).andExpect(status().isForbidden());
var csrfResult = mvc.perform(get("/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN");
assertThat(csrfCookie).isNotNull();
mvc.perform(
post("/probe")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfCookie.getValue()))
.andExpect(status().isOk());
FilterChainProxy proxy =
context.getBean("springSecurityFilterChain", FilterChainProxy.class);
SessionManagementFilter sessionManagement =
proxy.getFilterChains().getFirst().getFilters().stream()
.filter(SessionManagementFilter.class::isInstance)
.map(SessionManagementFilter.class::cast)
.findFirst()
.orElseThrow();
Object strategy =
ReflectionTestUtils.getField(
sessionManagement, "sessionAuthenticationStrategy");
assertThat(strategy).isInstanceOf(CompositeSessionAuthenticationStrategy.class);
assertThat(
(java.util.List<?>)
ReflectionTestUtils.getField(strategy, "delegateStrategies"))
.anyMatch(SessionFixationProtectionStrategy.class::isInstance);
} catch (Exception exception) {
throw new AssertionError("session security contract failed", exception);
}
});
}
@Test
void redisSessionSecurityFilterPersistsAndRestoresOnlyThePrimitiveAuthenticationSnapshot() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.public-paths=/login-test,/csrf",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class));
try {
var csrfResult = mvc.perform(get("/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN");
var login =
mvc.perform(
post("/login-test")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfCookie.getValue()))
.andExpect(status().isOk())
.andReturn();
MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);
assertThat(session).isNotNull();
assertThat(
session.getAttribute(
PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE))
.isInstanceOf(byte[].class);
assertThat(session.getAttribute("SPRING_SECURITY_CONTEXT")).isNull();
mvc.perform(get("/whoami").session(session))
.andExpect(status().isOk())
.andExpect(content().string("session-user"));
} catch (Exception exception) {
throw new AssertionError(
"primitive session security context round-trip failed", exception);
}
});
}
@Test
void jwtModeRemainsCsrfDisabledAndStateless() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=jwt",
"ca-skeleton.security.issuer-uri=https://issuer.example",
"ca-skeleton.security.public-paths=/probe",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class));
try {
var result = mvc.perform(post("/probe")).andExpect(status().isOk()).andReturn();
assertThat(result.getRequest().getSession(false)).isNull();
} catch (Exception exception) {
throw new AssertionError("JWT security contract failed", exception);
}
});
}
private static MockMvc mvc(Filter springSecurityFilterChain) {
return MockMvcBuilders.standaloneSetup(new ProbeController())
.addFilters(springSecurityFilterChain)
.build();
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({SecuritySettings.class, CorsSettings.class})
static class PropertiesConfig {}
@RestController
static class ProbeController {
@GetMapping("/probe")
String getProbe() {
return "ok";
}
@PostMapping("/probe")
String postProbe() {
return "ok";
}
@GetMapping("/csrf")
String csrf(HttpServletRequest request) {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
return token.getToken();
}
@PostMapping("/login-test")
String loginForContract() {
SecurityContextHolder.getContext()
.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(
"session-user", "session-user@example.test", java.util.Set.of("operator")),
null,
java.util.Set.of(new SimpleGrantedAuthority("ROLE_OPERATOR"))));
return "authenticated";
}
@GetMapping("/whoami")
String whoami() {
return ((AuthenticatedPrincipal)
SecurityContextHolder.getContext().getAuthentication().getPrincipal())
.idpUserId();
}
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.HandlerMapping;
class EdgeRateLimitTransportBridgeTest {
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-07-29T01:00:00Z"), ZoneOffset.UTC);
private static final String DIGEST = "v7:" + "b".repeat(64);
private static final String EVALUATION_ID = "ev9:" + "C".repeat(22);
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void sendsOnlyABoundedPseudonymousSubjectAndTransportBudgetToThePort() {
Capture capture = new Capture();
RateLimitOutcome expected =
new RateLimitOutcome.Evaluated(
new RateLimitDecision(
true,
100,
99,
Duration.ZERO,
CLOCK.instant().plusSeconds(1),
"api-default",
"v3",
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
RateLimitDecision.DecisionCertainty.CERTAIN));
EdgeRateLimitTransportBridge bridge =
new EdgeRateLimitTransportBridge(
request -> {
capture.request = request;
return expected;
},
subject -> {
capture.rawSubject = subject;
return new RateLimitSubjectDigest(DIGEST);
},
new RateLimitKeyResolver(new RemoteAddrClientIpResolver()),
CLOCK,
"api-default",
Duration.ofMillis(750),
() -> EVALUATION_ID);
AuthenticatedPrincipal principal =
new AuthenticatedPrincipal("raw-user-42", "raw@example.com", Set.of("user"));
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(principal, "n/a", Set.of()));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs/123");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}");
request.addHeader("Idempotency-Key", "client-controlled-value");
request.addHeader("X-Rate-Limit-Evaluation-Id", "ev1:" + "Z".repeat(22));
RateLimitOutcome actual = bridge.evaluate(request);
assertThat(actual).isSameAs(expected);
assertThat(capture.rawSubject)
.isEqualTo(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.PRINCIPAL, "raw-user-42", "GET /v1/worklogs/{id}"));
assertThat(capture.request.policyId()).isEqualTo("api-default");
assertThat(capture.request.subjectDigest()).isEqualTo(DIGEST);
assertThat(capture.request.subjectDigest())
.doesNotContain("raw-user-42")
.doesNotContain("raw@example.com");
assertThat(capture.request.cost()).isEqualTo(1);
assertThat(capture.request.evaluationId()).isEqualTo(EVALUATION_ID);
assertThat(capture.request.evaluationId())
.doesNotContain("client-controlled-value")
.doesNotContain("ZZZZ");
assertThat(capture.request.callerDeadline())
.isEqualTo(Instant.parse("2026-07-29T01:00:00.750Z"));
}
private static final class Capture {
private EdgeRateLimitSubject rawSubject;
private RateLimitRequest request;
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class EdgeRateLimitTransportSettingsTest {
@Test
void defaultsPolicyAndCallerBudgetWithoutSelectingALocalProvider() {
EdgeRateLimitTransportSettings settings =
new EdgeRateLimitTransportSettings(true, null, null, 0, null);
assertThat(settings.enabled()).isTrue();
assertThat(settings.defaultPolicyId()).isEqualTo("api-default");
assertThat(settings.callerDeadlineBudget()).isEqualTo(Duration.ofSeconds(2));
assertThat(settings.hashKeyVersion()).isEqualTo(1);
assertThat(settings.clientIpMode()).isEqualTo(RateLimitClientIpMode.REMOTE_ADDR_ONLY);
}
@Test
void rejectsUnboundedPolicyAndDeadlineValues() {
assertThatThrownBy(
() ->
new EdgeRateLimitTransportSettings(
true, "INVALID POLICY", Duration.ofSeconds(1), 1, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("defaultPolicyId");
assertThatThrownBy(
() ->
new EdgeRateLimitTransportSettings(
true, "api-default", Duration.ofSeconds(31), 1, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("callerDeadlineBudget");
assertThatThrownBy(
() ->
new EdgeRateLimitTransportSettings(
true, "api-default", Duration.ofSeconds(1), 10_000, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("hashKeyVersion");
}
}
@@ -1,84 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
class FixedWindowRateLimiterTest {
private static final Instant T0 = Instant.parse("2026-06-09T12:00:00Z");
@Test
void allowsUpToTheLimitThenRejectsWithinAWindow() {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(2, Duration.ofSeconds(1), Clock.fixed(T0, ZoneOffset.UTC));
assertThat(limiter.decide("k").allowed()).isTrue();
RateLimitDecision second = limiter.decide("k");
assertThat(second.allowed()).isTrue();
assertThat(second.remaining()).isZero();
RateLimitDecision third = limiter.decide("k");
assertThat(third.allowed()).isFalse();
assertThat(third.remaining()).isZero();
}
@Test
void separateKeysHaveIndependentCounters() {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(1, Duration.ofSeconds(1), Clock.fixed(T0, ZoneOffset.UTC));
assertThat(limiter.decide("a").allowed()).isTrue();
assertThat(limiter.decide("b").allowed()).isTrue();
assertThat(limiter.decide("a").allowed()).isFalse();
}
@Test
void counterResetsWhenTheWindowRolls() {
MutableClock clock = new MutableClock(T0);
FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(1, Duration.ofSeconds(1), clock);
assertThat(limiter.decide("k").allowed()).isTrue();
assertThat(limiter.decide("k").allowed()).isFalse();
clock.advance(Duration.ofSeconds(1)); // next fixed window
assertThat(limiter.decide("k").allowed()).isTrue();
}
@Test
void resetInstantIsTheWindowEnd() {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(5, Duration.ofSeconds(60), Clock.fixed(T0, ZoneOffset.UTC));
// T0 = 12:00:00 60s window starting at 12:00:00 ends at 12:01:00.
assertThat(limiter.decide("k").resetAt()).isEqualTo(Instant.parse("2026-06-09T12:01:00Z"));
}
static final class MutableClock extends Clock {
private Instant instant;
MutableClock(Instant start) {
this.instant = start;
}
void advance(Duration d) {
instant = instant.plus(d);
}
@Override
public Instant instant() {
return instant;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
}
}
@@ -3,75 +3,176 @@ package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.servlet.HandlerMapping;
import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
class RateLimitInterceptorTest { class RateLimitInterceptorTest {
private static final Clock CLOCK = private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-06-09T12:00:00Z"), ZoneOffset.UTC); Clock.fixed(Instant.parse("2026-06-09T12:00:00Z"), ZoneOffset.UTC);
private static final String SUBJECT_DIGEST = "v1:" + "a".repeat(64);
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private RateLimitInterceptor interceptor(boolean enabled, int limit) {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(limit, Duration.ofSeconds(1), CLOCK);
return new RateLimitInterceptor(
enabled,
limiter,
new RateLimitKeyResolver(new RemoteAddrClientIpResolver()),
objectMapper,
1);
}
private MockHttpServletRequest request() {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs");
req.setRemoteAddr("203.0.113.7");
return req;
}
@Test @Test
void allowedRequestPassesAndEmitsSignalingHeaders() throws Exception { void allowedRequestPassesAndEmitsExistingSignalingHeaders() throws Exception {
MockHttpServletResponse res = new MockHttpServletResponse(); RateLimitOutcome outcome =
evaluated(true, 5, 4, Duration.ZERO, Instant.parse("2026-06-09T12:00:01Z"));
MockHttpServletResponse response = new MockHttpServletResponse();
boolean proceed = interceptor(true, 5).preHandle(request(), res, new Object()); boolean proceed = interceptor(outcome).preHandle(request(), response, new Object());
assertThat(proceed).isTrue(); assertThat(proceed).isTrue();
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("5"); assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("5");
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_REMAINING)).isEqualTo("4"); assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_REMAINING)).isEqualTo("4");
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_RESET)).isEqualTo("2026-06-09T12:00:01Z"); assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_RESET)).isEqualTo("2026-06-09T12:00:01Z");
assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isNull();
} }
@Test @Test
void exceedingTheLimitRejectsWith429EnvelopeRetryAfterAndRetryableTrue() throws Exception { void deniedDecisionRejectsWith429AndUsesTheProviderRetryHint() throws Exception {
RateLimitInterceptor interceptor = interceptor(true, 1); RateLimitOutcome outcome =
// first request consumes the only slot evaluated(false, 1, 0, Duration.ofMillis(1500), Instant.parse("2026-06-09T12:00:02Z"));
interceptor.preHandle(request(), new MockHttpServletResponse(), new Object()); MockHttpServletResponse response = new MockHttpServletResponse();
MockHttpServletResponse res = new MockHttpServletResponse(); boolean proceed = interceptor(outcome).preHandle(request(), response, new Object());
boolean proceed = interceptor.preHandle(request(), res, new Object());
assertThat(proceed).isFalse(); assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(429); assertThat(response.getStatus()).isEqualTo(429);
assertThat(res.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("1"); assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("2");
assertThat(res.getContentAsString()) assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("1");
assertThat(response.getContentAsString())
.contains("\"RATE_LIMIT_EXCEEDED\"") .contains("\"RATE_LIMIT_EXCEEDED\"")
.contains("\"RATE_LIMIT\"") .contains("\"RATE_LIMIT\"")
.contains("\"retryable\":true"); .contains("\"retryable\":true");
} }
@Test @Test
void disabledLimiterPassesWithoutTouchingHeaders() throws Exception { void unavailableAndIndeterminateOutcomesMapTo503WithTheirOwnRetryHints() throws Exception {
MockHttpServletResponse res = new MockHttpServletResponse(); MockHttpServletResponse unavailableResponse = new MockHttpServletResponse();
MockHttpServletResponse indeterminateResponse = new MockHttpServletResponse();
boolean proceed = interceptor(false, 1).preHandle(request(), res, new Object()); boolean unavailableProceed =
interceptor(
new RateLimitOutcome.Unavailable(
"api-default",
Duration.ofMillis(100),
RateLimitOutcome.UnavailableCategory.UNAVAILABLE_BEFORE_SEND))
.preHandle(request(), unavailableResponse, new Object());
boolean indeterminateProceed =
interceptor(new RateLimitOutcome.Indeterminate("api-default", Duration.ofMillis(2500)))
.preHandle(request(), indeterminateResponse, new Object());
assertThat(unavailableProceed).isFalse();
assertThat(unavailableResponse.getStatus()).isEqualTo(503);
assertThat(unavailableResponse.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("1");
assertThat(unavailableResponse.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull();
assertThat(unavailableResponse.getContentAsString())
.contains("\"RATE_LIMIT_UNAVAILABLE\"")
.contains("\"retryable\":true");
assertThat(indeterminateProceed).isFalse();
assertThat(indeterminateResponse.getStatus()).isEqualTo(503);
assertThat(indeterminateResponse.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("3");
}
@Test
void incompatibleOutcomeMapsToNonRetryable503WithoutInventingARetryHint() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
boolean proceed =
interceptor(
new RateLimitOutcome.Incompatible(
"api-default", RateLimitOutcome.IncompatibleCategory.PROGRAM_INCOMPATIBLE))
.preHandle(request(), response, new Object());
assertThat(proceed).isFalse();
assertThat(response.getStatus()).isEqualTo(503);
assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isNull();
assertThat(response.getContentAsString())
.contains("\"RATE_LIMIT_INCOMPATIBLE\"")
.contains("\"retryable\":false");
}
@Test
void disabledModeHasNoProviderPseudonymizerOrResolverSideEffects() throws Exception {
AtomicInteger calls = new AtomicInteger();
EdgeRateLimitPort port =
request -> {
calls.incrementAndGet();
throw new AssertionError("disabled interceptor must not call the provider");
};
EdgeSubjectPseudonymizer pseudonymizer =
subject -> {
calls.incrementAndGet();
throw new AssertionError("disabled interceptor must not pseudonymize");
};
EdgeRateLimitTransportBridge unusedBridge =
new EdgeRateLimitTransportBridge(
port,
pseudonymizer,
new RateLimitKeyResolver(
request -> {
calls.incrementAndGet();
return request.getRemoteAddr();
}),
CLOCK,
"api-default",
Duration.ofSeconds(1),
() -> "ev1:" + "D".repeat(22));
MockHttpServletResponse response = new MockHttpServletResponse();
boolean proceed =
RateLimitInterceptor.disabled(objectMapper).preHandle(request(), response, unusedBridge);
assertThat(proceed).isTrue(); assertThat(proceed).isTrue();
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull(); assertThat(calls).hasValue(0);
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull();
}
private RateLimitInterceptor interceptor(RateLimitOutcome outcome) {
EdgeRateLimitTransportBridge bridge =
new EdgeRateLimitTransportBridge(
request -> outcome,
subject -> new RateLimitSubjectDigest(SUBJECT_DIGEST),
new RateLimitKeyResolver(new RemoteAddrClientIpResolver()),
CLOCK,
"api-default",
Duration.ofSeconds(1),
() -> "ev1:" + "D".repeat(22));
return RateLimitInterceptor.enabled(bridge, objectMapper);
}
private MockHttpServletRequest request() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs");
request.setRemoteAddr("203.0.113.7");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs");
return request;
}
private static RateLimitOutcome evaluated(
boolean allowed, long limit, long remaining, Duration retryAfter, Instant resetAt) {
return new RateLimitOutcome.Evaluated(
new RateLimitDecision(
allowed,
limit,
remaining,
retryAfter,
resetAt,
"api-default",
"v1",
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
RateLimitDecision.DecisionCertainty.CERTAIN));
} }
} }
@@ -3,6 +3,7 @@ package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import java.util.Set; import java.util.Set;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -27,34 +28,42 @@ class RateLimitKeyResolverTest {
} }
@Test @Test
void unauthenticatedKeyIsIpPlusRouteTemplate() { void unauthenticatedSubjectIsBoundedClientIpPlusRouteTemplate() {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs/123"); MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs/123");
req.setRemoteAddr("203.0.113.7"); req.setRemoteAddr("203.0.113.7");
req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}"); req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}");
assertThat(resolver.resolve(req)).isEqualTo("ip:203.0.113.7:GET /v1/worklogs/{id}"); assertThat(resolver.resolve(req))
.isEqualTo(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.CLIENT_IP, "203.0.113.7", "GET /v1/worklogs/{id}"));
} }
@Test @Test
void unauthenticatedKeyFallsBackToUriWhenNoPattern() { void unauthenticatedSubjectUsesABoundedFallbackWhenNoRouteTemplateExists() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/v1/worklogs"); MockHttpServletRequest req = new MockHttpServletRequest("POST", "/v1/worklogs");
req.setRemoteAddr("198.51.100.4"); req.setRemoteAddr("198.51.100.4");
assertThat(resolver.resolve(req)).isEqualTo("ip:198.51.100.4:POST /v1/worklogs"); assertThat(resolver.resolve(req).operationId()).isEqualTo("POST <unresolved-route>");
} }
@Test @Test
void authenticatedUserKeyIsKeyedByPrincipal() { void authenticatedUserSubjectIsPrincipalPlusOperation() {
authenticateAs(new AuthenticatedPrincipal("user-42", "u@x.io", Set.of("user"))); authenticateAs(new AuthenticatedPrincipal("user-42", "u@x.io", Set.of("user")));
assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs"))) MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs");
.isEqualTo("user:user-42"); request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs");
assertThat(resolver.resolve(request))
.isEqualTo(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.PRINCIPAL, "user-42", "GET /v1/worklogs"));
} }
@Test @Test
void servicePrincipalKeyIsKeyedByApiKeyId() { void servicePrincipalSubjectUsesApiKeyKind() {
authenticateAs(new AuthenticatedPrincipal("svc-7", "svc@x.io", Set.of("service"))); authenticateAs(new AuthenticatedPrincipal("svc-7", "svc@x.io", Set.of("service")));
assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs"))) assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs")).kind())
.isEqualTo("apikey:svc-7"); .isEqualTo(EdgeRateLimitSubject.Kind.API_KEY);
} }
@Test @Test
@@ -63,7 +72,7 @@ class RateLimitKeyResolverTest {
req.setRemoteAddr("10.0.0.1"); req.setRemoteAddr("10.0.0.1");
req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1"); req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1");
assertThat(resolver.resolve(req)).isEqualTo("ip:10.0.0.1:GET /v1/ping"); assertThat(resolver.resolve(req).canonicalIdentity()).isEqualTo("10.0.0.1");
} }
@Test @Test
@@ -74,7 +83,7 @@ class RateLimitKeyResolverTest {
req.setRemoteAddr("10.0.0.1"); req.setRemoteAddr("10.0.0.1");
req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1"); req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1");
assertThat(forwardedResolver.resolve(req)).isEqualTo("ip:203.0.113.9:GET /v1/ping"); assertThat(forwardedResolver.resolve(req).canonicalIdentity()).isEqualTo("203.0.113.9");
} }
@Test @Test
@@ -85,6 +94,6 @@ class RateLimitKeyResolverTest {
req.setRemoteAddr("198.51.100.4"); req.setRemoteAddr("198.51.100.4");
req.addHeader("X-Forwarded-For", " "); req.addHeader("X-Forwarded-For", " ");
assertThat(forwardedResolver.resolve(req)).isEqualTo("ip:198.51.100.4:GET /v1/ping"); assertThat(forwardedResolver.resolve(req).canonicalIdentity()).isEqualTo("198.51.100.4");
} }
} }
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import java.time.Clock;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import tools.jackson.databind.ObjectMapper;
class RateLimitWebConfigTest {
@Test
void disabledCapabilityDoesNotResolveProviderPseudonymizerOrClock() {
ObjectProvider<Clock> clockProvider = provider();
ObjectProvider<EdgeRateLimitPort> rateLimitPortProvider = provider();
ObjectProvider<UserPrincipalPseudonymizerPort> pseudonymizerProvider = provider();
new RateLimitWebConfig(
new EdgeRateLimitTransportSettings(false, null, null, 0, null),
new ObjectMapper(),
clockProvider,
rateLimitPortProvider,
pseudonymizerProvider);
verifyNoInteractions(clockProvider, rateLimitPortProvider, pseudonymizerProvider);
}
@SuppressWarnings("unchecked")
private static <T> ObjectProvider<T> provider() {
return mock(ObjectProvider.class);
}
}
@@ -1,28 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
class RateLimiterFactoryTest {
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-06-09T00:00:00Z"), ZoneOffset.UTC);
@Test
void fixedWindowAlgorithmBuildsAFixedWindowLimiter() {
RateLimiter limiter =
RateLimiterFactory.create(
RateLimitAlgorithm.FIXED_WINDOW, 10, Duration.ofSeconds(1), CLOCK);
assertThat(limiter).isInstanceOf(FixedWindowRateLimiter.class);
// returns the interface type so the interceptor never sees the concrete class
RateLimitDecision decision = limiter.decide("k");
assertThat(decision.allowed()).isTrue();
assertThat(decision.limit()).isEqualTo(10);
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import java.security.SecureRandom;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
class SecureRandomRateLimitEvaluationIdGeneratorTest {
@Test
void generatesVersionedBoundedServerSideIdsWithCryptographicRandomness() {
RateLimitEvaluationIdGenerator generator =
new SecureRandomRateLimitEvaluationIdGenerator(new SecureRandom(), 1);
Set<String> generated = new HashSet<>();
for (int index = 0; index < 100; index++) {
generated.add(generator.generate());
}
assertThat(generated).hasSize(100).allMatch(value -> value.matches("ev1:[A-Za-z0-9_-]{22}"));
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import org.junit.jupiter.api.Test;
class VersionedEdgeSubjectPseudonymizerTest {
@Test
void lengthFramesEveryDimensionBeforeDelegatingAndVersionsTheDigest() {
StringBuilder delegatedInput = new StringBuilder();
VersionedEdgeSubjectPseudonymizer pseudonymizer =
new VersionedEdgeSubjectPseudonymizer(
raw -> {
delegatedInput.append(raw);
return "c".repeat(64);
},
3);
assertThat(
pseudonymizer
.pseudonymize(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.CLIENT_IP,
"203.0.113.7",
"GET /v1/worklogs/{id}"))
.value())
.isEqualTo("v3:" + "c".repeat(64));
assertThat(delegatedInput).hasToString("9:CLIENT_IP|11:203.0.113.7|21:GET /v1/worklogs/{id}");
}
}
@@ -1,43 +0,0 @@
package dev.caskeleton.adapter.inbound.web.settings;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class RateLimitSettingsTest {
@Test
void bindsSuppliedValues() {
RateLimitSettings props =
new RateLimitSettings(
true,
250,
Duration.ofSeconds(5),
RateLimitAlgorithm.FIXED_WINDOW,
RateLimitClientIpMode.FORWARDED_HEADERS_TRUSTED);
assertThat(props.enabled()).isTrue();
assertThat(props.limit()).isEqualTo(250);
assertThat(props.window()).isEqualTo(Duration.ofSeconds(5));
assertThat(props.algorithm()).isEqualTo(RateLimitAlgorithm.FIXED_WINDOW);
assertThat(props.clientIpMode()).isEqualTo(RateLimitClientIpMode.FORWARDED_HEADERS_TRUSTED);
}
@Test
void defaultsAbsentOrInvalidLimitWindowAndAlgorithm() {
RateLimitSettings props = new RateLimitSettings(false, null, null, null, null);
assertThat(props.limit()).isEqualTo(100);
assertThat(props.window()).isEqualTo(Duration.ofSeconds(1));
assertThat(props.algorithm()).isEqualTo(RateLimitAlgorithm.FIXED_WINDOW);
assertThat(props.clientIpMode()).isEqualTo(RateLimitClientIpMode.REMOTE_ADDR_ONLY);
}
@Test
void rejectsNonPositiveLimitAndWindowWithSafeDefaults() {
RateLimitSettings props = new RateLimitSettings(true, 0, Duration.ZERO, null, null);
assertThat(props.limit()).isEqualTo(100);
assertThat(props.window()).isEqualTo(Duration.ofSeconds(1));
}
}
@@ -68,6 +68,27 @@ class SecuritySettingsTest {
assertThat(settings.publicPaths()).isUnmodifiable(); assertThat(settings.publicPaths()).isUnmodifiable();
} }
@Test
void redisSessionModeDoesNotRequireJwtAndBindsSecureHostOnlyCookiePolicy() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.session.cookie-name=APP_SESSION",
"ca-skeleton.security.session.secure=true",
"ca-skeleton.security.session.http-only=true",
"ca-skeleton.security.session.same-site=Strict")
.run(
context -> {
assertThat(context).hasNotFailed();
SecuritySettings settings = context.getBean(SecuritySettings.class);
assertThat(settings.authMode())
.isEqualTo(SecuritySettings.AuthenticationMode.REDIS_SESSION);
assertThat(settings.issuerUri()).isEmpty();
assertThat(settings.session().cookieName()).isEqualTo("APP_SESSION");
assertThat(settings.session().sameSite()).isEqualTo("Strict");
});
}
@Configuration @Configuration
@EnableConfigurationProperties(SecuritySettings.class) @EnableConfigurationProperties(SecuritySettings.class)
static class EnableProperties {} static class EnableProperties {}
@@ -15,6 +15,10 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
- Implement semantic cache ports from `application-core` without exposing Redis concepts to core. - Implement semantic cache ports from `application-core` without exposing Redis concepts to core.
- Own canonical physical keys, digesting, codec/envelope, program catalog, typed Redis atomic - Own canonical physical keys, digesting, codec/envelope, program catalog, typed Redis atomic
facades, runtime client adaptation, and capability-specific failure semantics. facades, runtime client adaptation, and capability-specific failure semantics.
- Implement absolute soft/hard expiry and deterministic bounded jitter behind the semantic cache
port; cache-aside/source protection policy remains framework-free in `application-core`.
- Implement the provider-neutral `EdgeRateLimitPort` with dedicated coordination Redis settings,
connection/admission, private keys and versioned atomic programs.
- Keep the legacy cache router isolated while consumers migrate to semantic ports. - 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.
@@ -24,10 +28,14 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
`src/config/architecture/modules.json` entry. `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.
- Physical Redis TTL must equal encoded hard expiry; future/corrupt schema must never collapse into
an ordinary miss.
- Application/domain code must not receive raw Redis keys, commands, Lua/Function names, SDK - Application/domain code must not receive raw Redis keys, commands, Lua/Function names, SDK
objects, topology, or connection types. objects, topology, or connection types.
- Cache fail-open behavior must not be reused for session, idempotency, strict quota, lease, or - Cache fail-open behavior must not be reused for session, idempotency, strict quota, lease, or
fencing. fencing.
- Rate-limit composition must not reuse `app.cache.redis`, its connection, external client mode or
failure-open semantics; v1 is coordination-role and fail-closed only.
- The standalone runtime/cache service lane is R1 evidence only. Sentinel/Cluster, TLS/ACL, - 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. persistence/restart, eviction and fault evidence are required separately for R2.
+259 -20
View File
@@ -10,16 +10,157 @@
## 현재 readiness ## 현재 readiness
현재 standalone runtime과 semantic string cache는 R1이다. 모듈이 Lettuce connection lifecycle, 현재 checked-in readiness registry에는 `selected` card가 없으므로 Redis R2 release claim도
없다.
| Capability card | 현재 상태 | Promotion topology |
| --- | --- | --- |
| cache | `implemented-candidate` | standalone |
| edge rate limit | `implemented-candidate` | standalone |
| request-replay idempotency | `implemented-candidate` | standalone |
| cache refresh soft lease | `implemented-candidate` | standalone |
| session | `implemented-candidate` | standalone |
| fenced coordination | `not-implemented` | 없음 |
`implemented-candidate`는 구현과 standalone/security/fault/compatibility evidence lane이 있다는
뜻일 뿐 release selection이나 R2 qualification이 아니다. 현재 evidence는 Sentinel/Cluster,
k3s multi-node, topology failover, credential/certificate rotation 또는 R3를 증명하지 않는다.
모듈은 Lettuce connection lifecycle,
finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative
TTL, digest-protected bounded binary envelope, HMAC physical key, TTL, absolute soft/hard expiry, deterministic bounded TTL jitter, digest-protected v2 binary
invalidation, Lua `EVALSHA -> NOSCRIPT -> EVAL` 실행기를 제공한다. envelope, HMAC physical key,
invalidation, closed-catalog
`EVALSHA -> NOSCRIPT -> SCRIPT LOAD -> digest verify -> EVALSHA` recovery를 제공한다.
`app.cache.redis.client-mode=external`이면 프로젝트가 제공한 `RedisClient` 호환 경로를 사용하고 `app.cache.redis.client-mode=external`이면 프로젝트가 제공한 `RedisClient` 호환 경로를 사용하고
managed connection을 생성하지 않는다. managed connection을 생성하지 않는다.
명시적으로 Redis 7.4 image를 띄워 실행하는 standalone lane이 실제 expiry 명시적으로 최소 지원 Redis 7.2 image를 띄워 실행하는 standalone lane이 실제 expiry,
compare-and-delete Lua 실행을 검증하지만 Sentinel/Cluster, compare-and-delete, cache `NX`, observation-token compare-and-replace, 세 rate-limit 프로그램,
TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가 없으므로 R2가 아니다. 각 프로그램의 exact-boundary/denial-no-consume, clock-regression state 불변,
token refill remainder와 malformed hash 분류를 검증한다. TLS named-user ACL에서 semantic
readiness의 `SCRIPT LOAD`/대표 명령 거부 증거는 있지만 Sentinel/Cluster, credential rotation,
restart/fault/eviction과 capability 전체의 운영 증거가 완성되지 않았으므로 R2가 아니다.
## Role policy와 health 경계
Canonical role binding은 startup에 다음 정책을 fail-closed로 검증한다.
- `CACHE`: `required=false`, `expected-eviction=allkeys-lfu|allkeys-lru`
- `COORDINATION`: `required=true`, `expected-eviction=noeviction`
- `SESSION`: `required=true`, `expected-eviction=noeviction`
Redis 모듈은 바인딩된 role router만 사용해 capability-aware semantic probe를 수행한다. PING만으로
ready를 선언하지 않는다. 모든 plan은 `ca-health:` namespace의 bounded opaque nonce key에 먼저
5초 TTL을 부여하고 SET/GET round trip을 검증한다. 선택 capability별 대표 프로그램은 다음과 같다.
- cache: `SET_IF_ABSENT_WITH_TTL`
- rate limit: `RATE_FIXED_WINDOW_V2`
- request-replay idempotency: `IDEMPOTENCY_CLAIM_V1`
- efficiency lease: `LEASE_ACQUIRE_V1`
- session: `SESSION_CREATE_V1`
대표 프로그램은 catalog digest의 `EVALSHA` 경로와 bounded result schema를 검증한다. 별도의
catalog-owned `semantic-capability-acl-v1` 프로그램은 Redis Lua API의
`redis.acl_check_cmd`로 대표 프로그램의 exact ACL command/key surface와 `SCRIPT LOAD` 권한을
비변경 방식으로 확인하고, `redis.REDIS_VERSION_NUM`으로 명시적인 Redis `>=7.2` policy gate를
먼저 적용한다. 두 Lua API 상수/함수는 Redis 7.0부터 제공되지만 이 템플릿이 지원을 선언하는
minimum은 7.2다. runtime identity에 허용해야 하는 probe key pattern은
`~ca-health:*`다. probe는 성공/실패와 무관하게 best-effort cleanup을 수행하고, cleanup이
거절돼도 모든 생성 key는 최대 5초 안에 만료된다.
각 role은 startup에 full semantic qualification을 완료한 관측을 seed한다. 이후 health scrape는
`APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL`(기본 5초) 동안 같은 관측을 재사용하고 role별
single-flight로만 refresh한다. refresh follower는 기다리지 않으며 15초 기본
`APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS` 안에서는 이전 관측과 `semanticObservedAt`,
`semanticAgeMillis`, `semanticStale=true`를 반환한다. 최대 staleness를 넘으면
`SEMANTIC_OBSERVATION_STALE`로 fail closed한다. eligibility와 age는 monotonic ticker를 사용해
wall-clock jump의 영향을 받지 않는다.
연결 가능한 optional/required role의 ACL, Redis 7.2 minimum, program result/schema mismatch는
모두 startup-fatal이다. 명확히 분류된 temporary connect/PING 실패만 optional CACHE를 dormant
route와 `COMMAND_UNAVAILABLE` 관측으로 시작하게 한다. health-triggered single-flight reconnect는
후보에 PING과 full semantic qualification을 모두 수행한 뒤에만 기존 router를 swap하며,
required COORDINATION/SESSION과 auth/TLS/material/unknown failure는 계속 fail closed한다.
Cluster에서 same-slot probe가 증명하는 범위는 해당 hash slot owner 한 노드뿐이다. 이 결과를
cluster 전체 노드나 failover target의 version/ACL/program 호환성 증거로 확대 해석하면 안 되며,
운영 promotion 전 별도의 cluster-wide 외부 conformance가 필요하다.
`shared-contract`의 framework-neutral snapshot은 role, 선택된 capability, availability,
sanitized reason, semantic observation metadata와 expected eviction만 제공한다. semantic success, read/write failure,
program ACL denial, program failure, admission saturation, recent command failure, closed route,
command unavailable, probe-in-progress, stale observation은 서로 다른 bounded reason이다. endpoint, deployment ID, key/value,
username, credential/trust reference와 server exception은 health detail에 노출하지 않는다.
Actuator 타입과 health-group 소유권은 `app-bootstrap`에 있다. CACHE 장애는
`redisOptional``state=DEGRADED` detail로만 나타나고 readiness를 내리지 않는다.
COORDINATION/SESSION 장애는 `redisRequired``DOWN`으로 만들며, 어떤 Redis contributor도
liveness에는 포함되지 않는다. role binding이 없으면 Redis client 생성과 Redis health
contributor 생성은 모두 0이다.
이 runtime은 Redis `CONFIG GET/SET` 권한을 요구하거나 노출하지 않는다. 따라서
`expected-eviction` 검증은 설정 의도에 대한 startup 검증이며 실제 server의
`maxmemory-policy`를 증명하지 않는다. Snapshot/health detail은 이 한계를
`CONFIGURED_EXPECTATION_ONLY`로, 외부 증거 상태를
`externalEvictionAttestation=INCOMPLETE`로 명시한다. 운영 readiness를 더 강하게 만들려면 배포
파이프라인의 외부 conformance job 또는 서명된 operator attestation으로 effective policy를
검증해야 한다. semantic probe는 runtime `CONFIG`/`ACL` 조회나 변경 권한을 요구하지 않는다.
## Distributed edge rate limit
`shared-contract``EdgeRateLimitPort` 뒤에서 fixed window, sliding-window counter, token bucket을
정확히 하나의 versioned Lua 실행으로 평가한다. 세 프로그램은 Redis `TIME`을 한 번만 읽고, server
time, bounded clock-regression clamp, denial-no-consume, finite state TTL과 정확히 7개 필드인 응답
계약을 공유한다. Redis `TYPE`의 status-table/string 차이를 정규화하고 malformed hash field는
typed incompatibility로 닫는다. Token bucket은 refill division remainder를 상태로 보존해 호출
빈도에 따라 quota가 달라지지 않는다. Sliding counter만 algorithm certainty가 approximate이고
나머지는 certain이다.
모든 closed program manifest의 `minimumRedisVersion`은 실제 minimum qualification lane과 같은
7.2다. 더 낮은 Redis 버전은 별도 service lane이 추가되기 전까지 호환을 주장하지 않는다.
## Redis-backed HTTP session
`redis-session` readiness card는 standalone을 선택 topology로 하는 implemented candidate다.
`RedisVersionedSessionRepository`는 Spring Session의 저장소 경계만 구현하고, 쿠키·CSRF·session
fixation 정책은 inbound web이 소유한다. 실제 Redis 상태 변경은 manifest로 닫힌 6개 Lua 프로그램
(create/inspect/save/touch/revoke/rotate)을 통해서만 수행한다.
- raw session ID는 physical key에 들어가지 않고 versioned HMAC digest로 변환된다.
- idle timeout과 absolute lifetime을 동시에 적용하며 touch 쓰기는 설정된 interval로 제한한다.
- logout은 revision `0`의 adapter-private force-revoke를 사용한다. 하나의 Lua 실행에서 tombstone을
먼저 만들고 live hash를 삭제하므로 concurrent stale save가 세션을 부활시킬 수 없다.
- rotation은 old ID tombstone과 new ID 생성을 원자적으로 수행한다. old/new ID가 서로 다른 Cluster
slot이므로 현재 activation은 standalone만 허용하고 Cluster와 Sentinel을 startup에서 거부한다.
- 저장 payload는 N/N-1 version을 읽는 명시적 primitive allowlist envelope다. Java serialization과
default typing을 쓰지 않는다. SHA-256 checksum은 우발적 손상 탐지용이며 authenticity 또는 공격자
변조 방지 보장이 아니다.
- timeout/response loss와 OOM은 성공이나 miss로 바꾸지 않고 unavailable/indeterminate로 닫는다.
별도 요청에서 같은 operation ID를 자동 재사용해 reconcile하지 않으므로 운영자는 timeout 뒤에
mutation 성공을 추정하면 안 된다.
현재 저장소는 의도적으로 unindexed baseline이다. principal lookup, 사용자 전체 logout,
maximum-concurrent-session 제어는 제공하지 않는다. 이 기능이 필요한 프로젝트는 별도 bounded index와
그 index의 원자성·복구 증거를 추가해야 한다. 현재 `card-redis-session` 레인은 같은 JVM 안의 서로
독립적인 두 runtime/repository client가 하나의 standalone Redis를 공유할 때의 logout/stale-save
race, TLS+named ACL, partition+`noeviction` OOM/recovery, Redis 7.2/7.4 compatibility를 검증한다.
이는 multi-process/pod, rolling deployment, pod/network failure qualification이 아니다.
아웃바운드 provider의 기본값은
`ca-skeleton.capabilities.rate-limit.provider=disabled`다. `redis`로 선택하면 canonical
`COORDINATION` role, `failure-policy=fail-closed`, default policy와 secret reference가 모두
필요하다. `app.rate-limit.enabled`는 HTTP transport enforcement만 제어하며 provider를 암묵적으로
선택하거나 fallback을 만들지 않는다. 설정은 `app.cache.redis`를 fallback으로 사용하지 않고,
`distributedRateLimiter`라는 semantic port bean만 외부에 제공한다. Caller deadline이 canonical
Redis command timeout보다 짧으면 command를 보내지 않고 typed no-mutation outcome을 반환한다.
Rate-limit physical key는 raw principal/IP/API key를 포함하지 않고 policy ID/revision/algorithm과
이미 pseudonymized된 subject digest를 다시 HMAC한다. Unknown policy/state/program/reply,
pre-send admission failure, post-dispatch indeterminate failure와 unsafe Redis clock을 서로 다른
outcome으로 보존하며 fail-open하지 않는다. 현재 standalone과 standalone TLS+named ACL의
`implemented-candidate` evidence가 있다. Sentinel/Cluster, topology failover,
credential/certificate rotation, effective eviction/persistence attestation과 R3 증거는 없으며,
checked-in `selected` card가 없으므로 R2 release claim도 없다.
## Application cache contract ## Application cache contract
@@ -36,6 +177,21 @@ TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가
TTL, jitter, codec, topology와 Redis SDK 타입은 이 port에 들어가지 않는다. 실제 product의 TTL, jitter, codec, topology와 Redis SDK 타입은 이 port에 들어가지 않는다. 실제 product의
use case는 `CacheRegionPort`를 상속한 semantic subtype을 정의해야 한다. use case는 `CacheRegionPort`를 상속한 semantic subtype을 정의해야 한다.
`application-core``CacheAsideExecutor`는 lookup/source/write 흐름을 공통화하고 다음을
보장한다.
- fresh/negative hit에서 source를 호출하지 않음;
- authoritative absence만 negative cache하고, miss refill은 `ONLY_IF_ABSENT`, stale/quarantine
refill은 `ONLY_IF_OBSERVED`로 기록;
- classified transient source failure에서만 hard expiry 전 stale fallback;
- local single-flight의 in-flight key/waiter bound와 abandoned-flight opportunistic cleanup;
- source bulkhead의 concurrency/admission/load deadline bound;
- unclassified exception과 interrupt/cancellation 보존.
동기 source loader는 cooperative cancellation token을 확인해야 한다. 임의 source 코드를
강제 종료하지 않으며, source가 token/deadline을 무시하면 bulkhead permit은 반환 시점까지
점유된다.
## Physical key ## Physical key
`RedisKeyBuilder`만 다음 canonical shape를 만든다. `RedisKeyBuilder`만 다음 canonical shape를 만든다.
@@ -50,24 +206,43 @@ version, 정확히 하나인 hash tag와 전체 UTF-8 byte bound를 검증한다
## Atomic program foundation ## Atomic program foundation
`redis/program-set.json`은 세 Lua resource의 exact digest, signature, status, complexity와 timeout `redis/*-program-set.json``redis/program-set.json`은 cache/rate/idempotency/lease/session 및
certainty를 기록한다. `RedisAtomicPrimitives`는 compare-delete, compare-expire, primitive Lua resource의 exact digest, signature, status, complexity와 timeout certainty를
set-if-absent-with-TTL을 typed result로 노출하고 unknown status를 compatibility failure로 기록한다. `RedisAtomicPrimitives`는 compare-delete,
처리한다. owner/value/operation/TTL은 Redis 호출 전에 제한된다. compare-expire, set-if-absent-with-TTL, replace-if-observed-with-TTL을 typed result로 노출하고
unknown status를 compatibility failure로 처리한다. owner/value/observation/operation/TTL은
Redis 호출 전에 제한된다. `redis/rate-program-set.json`은 structured rate-limit 프로그램의
별도 digest/signature/status manifest다.
Generic descriptor/catalog/executor와 typed primitive facade는 package-private collaborator다. Generic descriptor/catalog/executor와 typed primitive facade는 package-private collaborator다.
Spring composition에는 raw Redis key/value/TTL을 받는 bean을 노출하지 않으며, 이후 semantic Spring composition에는 raw Redis key/value/TTL을 받는 bean을 노출하지 않으며, 이후 semantic
port adapter가 내부에서만 이 facade를 사용한다. port adapter가 내부에서만 이 facade를 사용한다.
따라서 이 program set은 현재 internal R0 foundation이며, 실제 도메인 capability가 바로 소비할 이 primitive facade 자체는 application에 노출되는 범용 Redis port가 아니다. Cache, rate limit,
수 있는 production bean이나 application port가 아니다. idempotency, soft lease, session의 semantic provider만 closed catalog를 내부에서 소비하며, 이
구조 자체가 release selection이나 R2 qualification을 뜻하지 않는다.
`RedisLuaProgramExecutor`가 catalog source로 SHA-1 script identity를 계산하여 `EVALSHA`를 먼저 `RedisLuaProgramExecutor`가 catalog source로 SHA-1 script identity를 계산하여 `EVALSHA`를 먼저
호출하고 정확히 `NOSCRIPT`일 때만 compiled script를 `EVAL`한다. signature/argument bounds는 호출하고 정확히 `NOSCRIPT`일 때만 catalog script를 `SCRIPT LOAD`한다. 반환 digest가 예상 identity와
같은지 확인한 뒤 `EVALSHA`를 한 번만 재시도한다. signature/argument bounds는
client 호출 전에 다시 검증하고 descriptor catalog membership 및 반환 status membership을 client 호출 전에 다시 검증하고 descriptor catalog membership 및 반환 status membership을
확인한다. unit lane은 강제 `NOSCRIPT` fallback을 검증하고 standalone real-service lane은 확인한다. unit lane은 강제 `NOSCRIPT` load/retry를 검증하고 standalone real-service lane은
compare-and-delete의 실제 atomic execution을 검증한다. compare-and-delete, NX, bounded trailing-digest observed replace, concurrent-writer 보존을 실제
Redis 7.2에서 검증한다. 같은 lane은 16MiB payload의 record/read/observed-replace와
16MiB+1 사전 거부, mutation interrupt의 `INDETERMINATE` certainty와 interrupt flag 복원도
실행한다.
## Managed runtime과 semantic region ## Managed runtime과 semantic region
Canonical activation은
`ca-skeleton.capabilities.cache.bindings.default=redis`
`ca-skeleton.providers.redis.roles.cache`를 함께 요구한다. 전자는 semantic policy를, 후자는
topology/TLS/ACL credential을 소유한다. Canonical region은 legacy `app.cache.redis.host`,
`password`, raw HMAC 값을 읽지 않고 CACHE role router와
`RedisCredentialMaterialProvider``secret://` reference만 사용한다. 같은 CACHE router가 L2
command와 invalidation Pub/Sub을 함께 route하므로 topology rotation 때 새 subscription ACK가
확인된 뒤 route가 교체된다. Canonical/legacy 동시 활성은 precedence를 추측하지 않고 startup에서
거절한다. 현재 템플릿이 자동 조합하는 semantic region ID는 `default` 하나이며, 여러 product
region은 region registry/compiler가 추가되기 전까지 자동 생성한다고 주장하지 않는다.
`app.cache.redis.enabled=true`이고 `client-mode=managed`(기본값)이면 `LettuceRedisRuntime` `app.cache.redis.enabled=true`이고 `client-mode=managed`(기본값)이면 `LettuceRedisRuntime`
단일 binary connection을 생성하고 종료 시 connection/client를 닫는다. 프로젝트가 단일 binary connection을 생성하고 종료 시 connection/client를 닫는다. 프로젝트가
`RedisClient`를 직접 제공하는 경우에는 `client-mode=external`을 명시해야 한다. 이 선택을 `RedisClient`를 직접 제공하는 경우에는 `client-mode=external`을 명시해야 한다. 이 선택을
@@ -86,13 +261,24 @@ opaque source revision에는 대소 비교 의미가 없으므로
`ONLY_IF_SOURCE_REVISION_NEWER`는 임의 lexical comparison을 하지 않고 `ONLY_IF_SOURCE_REVISION_NEWER`는 임의 lexical comparison을 하지 않고
`NOT_RECORDED_PROVIDER_POLICY`를 반환한다. `NOT_RECORDED_PROVIDER_POLICY`를 반환한다.
Envelope는 source revision의 application invariant(1..128 characters)를 decode 때도 다시 Envelope v2는 source revision, soft/hard absolute expiry와 payload를 digest로 보호한다.
검사하고 canonical bytes의 SHA-256 digest가 맞지 않으면 corrupt schema result로 격리한다. `soft <= now < hard`는 stale, `hard <= now`는 expired miss다. Retired v1은 명시적 quarantine
후 reload 대상이고 future/corrupt envelope는 fail-fast다. Integrity digest를 version byte보다
먼저 검사하며, digest가 맞더라도 현재 v2 구조가 잘못되면 corrupt로 분류한다. Stale/retired
lookup은 envelope digest를 opaque observation token으로 전달하고, cache-aside는 Lua에서 현재
digest가 그 token과 같을 때만 새 envelope로 교체한다. 따라서 조회와 refresh 사이의 writer를
삭제하거나 덮어쓰지 않는다. Source revision의 application invariant (1..128 characters)는
decode 때도 다시 검사한다.
`positive-soft-ttl`, 기존 `positive-ttl`(hard), `negative-ttl`, `ttl-jitter`,
`minimum-hard-ttl`은 startup에 immutable policy로 freeze된다. Jitter는 HMAC-derived physical
key와 policy revision으로 결정적이며 positive soft/hard에는 같은 factor를 적용한다. Redis
physical TTL은 envelope에 기록된 hard expiry와 같다.
추가 runtime setting은 `app.cache.redis.maximum-queued-commands=8`(범위 `1..4096`)과 추가 runtime setting은 `app.cache.redis.maximum-queued-commands=8`(범위 `1..4096`)과
`app.cache.redis.maximum-in-flight-bytes=16777216`이다. command count와 retained `app.cache.redis.maximum-in-flight-bytes=16777216`이다. 최대 readable envelope와 최대 command
request/response byte budget을 모두 통과해야 Lettuce 호출을 시작하며, byte를 별도로 계산하며, command count와 retained request/response byte budget을 모두 통과해야
`queue-count × (maximum-value-bytes + overhead)`도 byte bound 이하여야 한다. 이 관계는 Lettuce 호출을 시작한다. `queue-count × maximum-command-bytes`도 byte bound 이하여야 한다. 이 관계는
timeout 완료 뒤 driver가 응답 decode 전까지 command args를 유지하는 경우도 유한하게 제한한다. timeout 완료 뒤 driver가 응답 decode 전까지 command args를 유지하는 경우도 유한하게 제한한다.
timeout 직후에는 runtime admission population과 Lettuce retained population이 겹칠 수 있으므로 timeout 직후에는 runtime admission population과 Lettuce retained population이 겹칠 수 있으므로
최악 상한은 대략 `maximum-in-flight-bytes + queue-count × per-command-bound`이고, 설정 검증은 최악 상한은 대략 `maximum-in-flight-bytes + queue-count × per-command-bound`이고, 설정 검증은
@@ -104,6 +290,58 @@ Redis가 wire에 내보내는 bulk reply 자체를 `maximum-envelope-bytes + 1`
Netty/codec에 먼저 할당하지 않는다. managed runtime을 활성화할 때 host가 누락되면 Netty/codec에 먼저 할당하지 않는다. managed runtime을 활성화할 때 host가 누락되면
`localhost`로 암묵 fallback하지 않고 startup을 실패시킨다. `localhost`로 암묵 fallback하지 않고 startup을 실패시킨다.
Generation/revision fence는 mass/per-key invalidation과 source-load race를 막는다. Distributed
refresh soft lease는 정상 시 중복 refresh를 줄이지만 TTL expiry/crash에서는 duplicate owner를
허용하며, cache generation fence를 대체하는 correctness lock이 아니다.
`app.cache.redis.l1.enabled=true`는 semantic string cache 앞에만 optional local L1을 붙인다.
L1은 maximum entries, maximum accounted weight, per-entry accounted weight, local TTL, generation
recheck interval과 invalidation subscriber queue를 모두 finite하게 검증한다. Local expiry는 Redis
envelope hard expiry보다 길어질 수 없다. Weight는 HMAC-derived local identity와 UTF-8 value,
entry/lookup metadata에 대한 고정 conservative allowance를 더한 admission/eviction accounting
proxy이며, JVM heap reservation이나 실제 object layout의 exact byte guarantee가 아니다.
Invalidation Pub/Sub payload는 raw semantic key를 포함하지 않고 HMAC-authenticated bounded
message를 사용한다. Pub/Sub은 durable/exact invalidation 원장이 아니라 eviction hint다. Subscriber
disconnect나 queue overflow는 L1 전체를 flush하고, monotonic local invalidation epoch가 진행 중인
generation probe와 refill admission을 무효화한다. 재연결 뒤 generation을 다시 읽기 전에는 L1
admission을 허용하지 않는다. Hint 유실 시 mass invalidation은 periodic generation recheck,
per-key invalidation은 local TTL 안에서 Redis L2로 복귀한다.
이 local tier는 cache-only internal type을 요구하므로 session, idempotency, strict rate-limit,
coordination provider에 적용할 수 없다. 해당 capability들은 local fail-open cache semantics를
재사용하지 않는다.
Refresh-ahead와 probabilistic early refresh는 아직 구현하지 않았다. 둘 다 correctness baseline이
아니며, refresh-ahead는 명시적인 bounded hot-set registry/scheduler 없이 full keyspace scan으로
대체하지 않는다. Probabilistic early refresh도 versioned probability descriptor와 deterministic
property test가 생기기 전에는 readiness guarantee로 광고하지 않는다. Cache card에는 standalone
TLS+named ACL과 bounded fault evidence가 있지만 Sentinel/Cluster Pub/Sub/failover,
credential/certificate rotation, persistence/restart, effective eviction attestation,
multi-process/pod L1/L2 coherence와 R3 qualification은 아직 없다.
## Efficiency-only lease
`ca-skeleton.capabilities.lease.provider=redis`를 명시한 경우에만
`DistributedLeasePort`가 생성되며, canonical `COORDINATION` role router와 별도 HMAC secret
reference를 사용한다. 미선택 상태에서는 lease bean, secret resolution, native client와 thread
side effect가 모두 0이다.
이 port의 guarantee는 오직 `EFFICIENCY_ONLY`다. acquire/inspect/renew/release는 같은
owner token과 operation ID를 비교하고, response loss를 성공이나 실패로 추측하지 않고
`INDETERMINATE`/`UNKNOWN`으로 유지한다. caller가 최초 send 전에 보관한 같은 attempt로 inspect
또는 acquire replay를 해야 ownership을 복구할 수 있다. Handle validity는 Redis가 보고한 remaining
TTL에서 command 왕복 monotonic elapsed와 drift budget을 차감하며, server expiry wall clock은
telemetry 용도일 뿐이다. Watchdog는 worker와 registration 수, renewal cadence, application
deadline이 모두 유한하고 lease loss/unknown에서 작업 취소 callback을 한 번만 전달한다.
`redisEfficiencyLeaseTest`는 pinned Redis 7.2와 다음/승인 버전에서 standalone concurrency,
TLS/ACL, partition/response uncertainty와 compatibility를 별도 qualification한다. 이 test는
readiness card가 아니며 cache-refresh soft lease나 fenced coordination의 증거로 재사용되지
않는다. Fencing token과 protected-resource stale-token rejection은 구현하지 않았으므로
`redis-fenced-coordination` card는 계속 `not-implemented`다. 이 lease만으로 결제, 재고,
unique ID 또는 외부 장치 command 같은 correctness-sensitive write를 승인하면 안 된다.
## Legacy path ## Legacy path
기존 `CacheStoreRouter`, `RedisCacheStore`, `FailOpenCacheStore`는 호환성을 위해 남아 있다. 이 기존 `CacheStoreRouter`, `RedisCacheStore`, `FailOpenCacheStore`는 호환성을 위해 남아 있다. 이
@@ -118,4 +356,5 @@ cd src
./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain ./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain
./gradlew :adapter:outbound:cache-redis:redisServiceTest \ ./gradlew :adapter:outbound:cache-redis:redisServiceTest \
-Dredis.test.host=127.0.0.1 -Dredis.test.port=6379 --console=plain -Dredis.test.host=127.0.0.1 -Dredis.test.port=6379 --console=plain
./gradlew :adapter:outbound:cache-redis:redisEfficiencyLeaseTest --console=plain
``` ```
@@ -4,12 +4,35 @@ dependencies {
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 'org.springframework.session:spring-session-core'
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'org.springframework.data:spring-data-redis'
implementation 'io.lettuce:lettuce-core' implementation 'io.lettuce:lettuce-core'
implementation 'io.micrometer:micrometer-core'
implementation 'org.slf4j:slf4j-api' implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
} }
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
sourceSets {
redisTest {
java.srcDir 'src/redisTest/java'
resources.srcDir 'src/redisTest/resources'
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
}
}
configurations {
redisTestImplementation.extendsFrom testImplementation
redisTestCompileOnly.extendsFrom testCompileOnly
redisTestRuntimeOnly.extendsFrom testRuntimeOnly
}
dependencies {
redisTestImplementation 'org.testcontainers:testcontainers'
}
tasks.named('test') { tasks.named('test') {
useJUnitPlatform { useJUnitPlatform {
excludeTags 'redis-service' excludeTags 'redis-service'
@@ -32,3 +55,562 @@ tasks.register('redisServiceTest', Test) {
} }
shouldRunAfter tasks.named('test') shouldRunAfter tasks.named('test')
} }
def verifyRedisEvidenceSourcesPresent = tasks.register('verifyRedisEvidenceSourcesPresent') {
group = 'redis verification'
description = 'Fails readiness lanes when the redisTest evidence source set is empty.'
inputs.files(sourceSets.redisTest.allSource)
doLast {
Set<File> javaSources = sourceSets.redisTest.java.files.findAll {
it.isFile() && it.name.endsWith('.java')
}
if (javaSources.isEmpty()) {
throw new GradleException(
'Redis evidence source set is empty; readiness tasks must not pass as NO-SOURCE.')
}
File imageRegistry = rootProject.file('gradle/redis-test-images.properties')
if (!imageRegistry.isFile() || imageRegistry.length() == 0) {
throw new GradleException(
"Redis evidence image registry is missing or empty: ${imageRegistry}")
}
}
}
def redisCapabilityMetadata = rootProject.ext.redisCapabilityMetadata
def redisSanitizedEvidenceFileNames = [
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
] as Set<String>
def redisSanitizedBundleSha256 = { File directory ->
java.security.MessageDigest digest = java.security.MessageDigest.getInstance('SHA-256')
redisSanitizedEvidenceFileNames.toList().sort().each { String name ->
File file = new File(directory, name)
if (!file.isFile()) {
throw new GradleException(
"Redis sanitized bundle is missing ${name}: ${directory}")
}
byte[] nameBytes = name.getBytes('UTF-8')
byte[] contentBytes = file.bytes
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(nameBytes.length).array())
digest.update(nameBytes)
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array())
digest.update(contentBytes)
}
digest.digest().encodeHex().toString()
}
def registerRedisEvidenceTask = { String taskName, String tagExpression, String descriptionText ->
def evidenceTask = tasks.register(taskName, Test) {
group = 'redis verification'
description = descriptionText
dependsOn verifyRedisEvidenceSourcesPresent
testClassesDirs = sourceSets.redisTest.output.classesDirs
classpath = sourceSets.redisTest.runtimeClasspath
useJUnitPlatform {
includeTags tagExpression
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
systemProperty 'redis.image.registry',
rootProject.file('gradle/redis-test-images.properties').absolutePath
List<Map<String, Object>> sanitizedTimeline = []
List<String> declaredTags = tagExpression.split(/\s*&\s*/).toList()
String cardTag = declaredTags.find { it.startsWith('card-') }
String cardId = cardTag == null ? null : cardTag.substring('card-'.length())
Set<String> evidenceCategories = [
'standalone',
'security',
'sentinel',
'cluster',
'fault',
'compatibility'
] as Set<String>
String evidenceCategory = declaredTags.find {
it.startsWith('redis-') && evidenceCategories.contains(it.substring('redis-'.length()))
}
if (evidenceCategory != null) {
evidenceCategory = evidenceCategory.substring('redis-'.length())
}
File evidenceDirectory = layout.buildDirectory.dir(
"redis-evidence/${taskName}").get().asFile
outputs.dir evidenceDirectory
afterTest { descriptor, result ->
String identity = "${descriptor.className ?: ''}#${descriptor.name ?: ''}"
String identityDigest = java.security.MessageDigest.getInstance('SHA-256')
.digest(identity.getBytes('UTF-8')).encodeHex().toString()
sanitizedTimeline << [
sequence : sanitizedTimeline.size() + 1,
testCaseIdSha256: identityDigest,
outcome : result.resultType.name(),
durationMillis : Math.max(0L, result.endTime - result.startTime)
]
}
afterSuite { descriptor, result ->
if (descriptor.parent != null) {
return
}
evidenceDirectory.mkdirs()
Map<String, Object> card = cardId == null
? null
: rootProject.ext.redisReadinessCards[cardId] as Map<String, Object>
Map<String, String> digests = rootProject.ext.redisEvidenceDigests()
Map<String, Object> metadata = cardId == null
? [
providerIds : [],
roles : [],
programs : [],
keyVersions : [],
codecVersions : [],
guarantees : ['cross-cutting Redis evidence lane'],
nonGuarantees : ['does not qualify a capability card by itself'],
requiredSettings: []
]
: redisCapabilityMetadata[cardId] as Map<String, Object>
Map<String, Object> capabilityCard = [
schemaVersion : 1,
cardId : cardId,
readiness : card?.state,
releaseQualification: 'NOT_CLAIMED',
promotionTopology : card?.selectedTopology,
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
minimumRedisVersion: '7.2',
providerIds : metadata.providerIds,
roles : metadata.roles,
programIds : metadata.programs,
keyVersions : metadata.keyVersions,
codecVersions : metadata.codecVersions,
guarantees : metadata.guarantees,
nonGuarantees : metadata.nonGuarantees,
requiredSettings : metadata.requiredSettings,
evidenceProfile : card?.requiredEvidence ?: []
]
File capabilityCardFile = new File(evidenceDirectory, 'capability-card.json')
capabilityCardFile.setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(capabilityCard)) + '\n',
'UTF-8')
Map<String, Object> timeline = [
schemaVersion: 1,
taskName : taskName,
cardId : cardId,
topology : card?.selectedTopology,
evidence : evidenceCategory,
timelineKind : 'SANITIZED_TEST_RESULT',
actualEventTimeline: 'NOT_CAPTURED',
sourceRevision: rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState: rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
events : sanitizedTimeline
]
File timelineFile = new File(evidenceDirectory, 'topology-fault-timeline.json')
timelineFile.setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(timeline)) + '\n',
'UTF-8')
Closure<String> sha256 = { File file ->
java.security.MessageDigest.getInstance('SHA-256')
.digest(file.bytes).encodeHex().toString()
}
String outcome = result.resultType.name() == 'FAILURE'
? 'failed'
: (result.testCount == 0 || result.skippedTestCount > 0
? 'skipped-with-reason'
: 'executed')
Map<String, Object> manifest = [
schemaVersion : 1,
taskPath : path,
tagExpression : tagExpression,
cardId : cardId,
cardState : card?.state,
selectedTopology : card?.selectedTopology,
evidenceCategory : evidenceCategory,
outcome : outcome,
tests : [
discovered: result.testCount,
executed : result.testCount - result.skippedTestCount,
passed : result.successfulTestCount,
failed : result.failedTestCount,
errors : 0,
skipped : result.skippedTestCount
],
runtimeImageAttestation: 'NOT_CAPTURED',
actualEventTimeline: 'NOT_CAPTURED',
releaseQualification: 'NOT_CLAIMED',
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
companionSha256 : [
capabilityCardSha256: sha256(capabilityCardFile),
timelineSha256 : sha256(timelineFile)
]
]
new File(evidenceDirectory, 'manifest.json').setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(manifest)) + '\n',
'UTF-8')
}
doFirst {
[
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
].each { String generatedFile ->
new File(evidenceDirectory, generatedFile).delete()
}
layout.buildDirectory.file(
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile.delete()
Set<File> matchingSources = sourceSets.redisTest.java.files.findAll { File source ->
if (!source.isFile() || !source.name.endsWith('.java')) {
return false
}
String content = source.getText('UTF-8')
declaredTags.every { String tag -> content.contains("@Tag(\"${tag}\")") }
}
if (matchingSources.isEmpty()) {
throw new GradleException(
"${taskName}: no redisTest source declares every required tag " +
"${declaredTags}; zero-evidence readiness must not pass.")
}
}
}
def sanitizerTask = tasks.register("${taskName}SanitizeEvidence") {
group = 'redis verification'
description = "Validates the bounded sanitized artifact for ${taskName} before upload."
mustRunAfter evidenceTask
File sanitizerMarker = layout.buildDirectory.file(
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile
doFirst {
sanitizerMarker.delete()
}
doLast {
File evidenceDirectory = layout.buildDirectory.dir(
"redis-evidence/${taskName}").get().asFile
if (!evidenceDirectory.isDirectory()) {
throw new GradleException(
"${taskName}: sanitized evidence directory was not generated")
}
Set<String> allowedNames = redisSanitizedEvidenceFileNames
List<File> files = evidenceDirectory.listFiles()?.findAll { it.isFile() } ?: []
if (files.collect { it.name } as Set<String> != allowedNames ||
evidenceDirectory.listFiles()?.any { it.isDirectory() }) {
throw new GradleException(
"${taskName}: sanitized evidence must contain exactly ${allowedNames}")
}
files.each { File file ->
if (file.length() > 1_048_576L ||
java.nio.file.Files.isSymbolicLink(file.toPath()) ||
!file.toPath().toRealPath().startsWith(
evidenceDirectory.toPath().toRealPath())) {
throw new GradleException(
"${taskName}: oversized, symlinked, or path-escaping artifact ${file}")
}
String text = file.getText('UTF-8')
Map<String, java.util.regex.Pattern> forbidden = [
pem : java.util.regex.Pattern.compile(
'(?i)-----BEGIN [^-]*(?:PRIVATE KEY|CERTIFICATE)-----'),
aclMaterial : java.util.regex.Pattern.compile(
"(?i)(?:users\\.acl|--pass|[\"']password[\"']\\s*:)"),
uriUserInfo : java.util.regex.Pattern.compile(
'(?i)rediss?://[^\\s/@:]+:[^\\s/@]+@'),
secretReference : java.util.regex.Pattern.compile('(?i)secret://'),
rawMessageFields : java.util.regex.Pattern.compile(
'(?i)"(?:stackTrace|systemOut|systemErr|exception|containerId|host|ip|port|endpoint|rawKey|physicalKey|value|sessionId|csrf|idempotencyToken|ownerToken|operationToken)"\\s*:')
]
forbidden.each { String marker, java.util.regex.Pattern pattern ->
if (pattern.matcher(text).find()) {
throw new GradleException(
"${taskName}: sanitized artifact ${file.name} contains forbidden ${marker} material")
}
}
}
Map<String, Object> manifest = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'manifest.json')) as Map<String, Object>
Map<String, Object> capability = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'capability-card.json')) as Map<String, Object>
Map<String, Object> timeline = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'topology-fault-timeline.json')) as Map<String, Object>
Set<String> manifestFields = [
'schemaVersion',
'taskPath',
'tagExpression',
'cardId',
'cardState',
'selectedTopology',
'evidenceCategory',
'outcome',
'tests',
'runtimeImageAttestation',
'actualEventTimeline',
'releaseQualification',
'sourceRevision',
'sourceTreeState',
'digests',
'companionSha256'
] as Set<String>
Set<String> capabilityFields = [
'schemaVersion',
'cardId',
'readiness',
'releaseQualification',
'promotionTopology',
'sourceRevision',
'sourceTreeState',
'digests',
'minimumRedisVersion',
'providerIds',
'roles',
'programIds',
'keyVersions',
'codecVersions',
'guarantees',
'nonGuarantees',
'requiredSettings',
'evidenceProfile'
] as Set<String>
Set<String> timelineFields = [
'schemaVersion',
'taskName',
'cardId',
'topology',
'evidence',
'timelineKind',
'actualEventTimeline',
'sourceRevision',
'sourceTreeState',
'digests',
'events'
] as Set<String>
if (manifest.keySet() != manifestFields ||
capability.keySet() != capabilityFields ||
timeline.keySet() != timelineFields ||
(manifest.tests as Map).keySet() != [
'discovered',
'executed',
'passed',
'failed',
'errors',
'skipped'
] as Set<String> ||
(manifest.digests as Map).keySet() != [
'registrySha256',
'imageRegistrySha256',
'programSetSha256',
'configurationSha256'
] as Set<String> ||
(manifest.companionSha256 as Map).keySet() != [
'capabilityCardSha256',
'timelineSha256'
] as Set<String>) {
throw new GradleException(
"${taskName}: sanitized evidence contains unknown or missing schema fields")
}
List<Map<String, Object>> events = timeline.events as List<Map<String, Object>>
if (events.size() > 10_000 ||
events.withIndex().any { Map<String, Object> event, int index ->
event.keySet() != [
'sequence',
'testCaseIdSha256',
'outcome',
'durationMillis'
] as Set<String> ||
event.sequence != index + 1 ||
!(event.testCaseIdSha256 ==~ /[0-9a-f]{64}/) ||
!(event.outcome in ['SUCCESS', 'FAILURE', 'SKIPPED']) ||
!(event.durationMillis instanceof Number) ||
(event.durationMillis as Number).longValue() < 0L
}) {
throw new GradleException(
"${taskName}: sanitized test summary contains malformed events")
}
if ((capability.requiredSettings as List).any {
!(it instanceof Map) ||
(it as Map).keySet() != ['name', 'type', 'constraint'] as Set<String>
}) {
throw new GradleException(
"${taskName}: capability card required settings are not a safe name/type/constraint projection")
}
Map<String, Object> tests = manifest.tests as Map<String, Object>
if (!(manifest.outcome in ['executed', 'failed', 'skipped-with-reason']) ||
events.size() != (tests.discovered as Number).intValue() ||
events.count { it.outcome == 'SUCCESS' } !=
(tests.passed as Number).intValue() ||
events.count { it.outcome == 'FAILURE' } !=
(tests.failed as Number).intValue() ||
events.count { it.outcome == 'SKIPPED' } !=
(tests.skipped as Number).intValue()) {
throw new GradleException(
"${taskName}: manifest outcome/counts do not match the sanitized test summary")
}
if (manifest.outcome == 'executed' &&
((tests.discovered as Number).longValue() <= 0L ||
(tests.executed as Number).longValue() <= 0L ||
(tests.passed as Number).longValue() <= 0L ||
(tests.failed as Number).longValue() != 0L ||
(tests.errors as Number).longValue() != 0L ||
(tests.skipped as Number).longValue() != 0L)) {
throw new GradleException(
"${taskName}: executed evidence must be positive with zero failure/error/skip")
}
if (manifest.outcome == 'failed' &&
(tests.failed as Number).longValue() <= 0L) {
throw new GradleException(
"${taskName}: failed evidence must retain a positive bounded failure count")
}
sanitizerMarker.parentFile.mkdirs()
String bundleSha = redisSanitizedBundleSha256(evidenceDirectory)
sanitizerMarker.setText("${bundleSha}\n", 'UTF-8')
if (manifest.outcome == 'skipped-with-reason') {
throw new GradleException(
"${taskName}: skipped or zero-executed evidence is not a passing readiness lane")
}
}
}
evidenceTask.configure {
finalizedBy sanitizerTask
}
evidenceTask
}
tasks.register('verifyRedisEvidenceArtifactsForUpload') {
group = 'redis verification'
description = 'Allows CI upload only when every generated Redis evidence directory was sanitized.'
doLast {
File evidenceRoot = layout.buildDirectory.dir('redis-evidence').get().asFile
File markerRoot = layout.buildDirectory.dir('redis-evidence-sanitizer').get().asFile
List<File> evidenceDirectories = evidenceRoot.isDirectory()
? evidenceRoot.listFiles().findAll { it.isDirectory() }
: []
if (evidenceDirectories.isEmpty()) {
throw new GradleException(
'No sanitized Redis evidence directory exists for upload')
}
Set<String> evidenceTasks = evidenceDirectories.collect { it.name } as Set<String>
Set<String> markerTasks = markerRoot.isDirectory()
? markerRoot.listFiles().findAll {
it.isFile() && it.name.endsWith('.sha256')
}.collect {
it.name.substring(0, it.name.length() - '.sha256'.length())
} as Set<String>
: [] as Set<String>
if (evidenceTasks != markerTasks) {
throw new GradleException(
"Redis evidence upload sanitizer coverage mismatch; evidence=${evidenceTasks}, markers=${markerTasks}")
}
evidenceDirectories.each { File directory ->
Set<String> files = directory.listFiles().findAll { it.isFile() }
.collect { it.name } as Set<String>
if (files != redisSanitizedEvidenceFileNames) {
throw new GradleException(
"Redis upload directory ${directory.name} is outside the sanitized allowlist")
}
String bundleSha = redisSanitizedBundleSha256(directory)
String recordedSha = new File(
markerRoot, "${directory.name}.sha256").getText('UTF-8').trim()
if (recordedSha != bundleSha) {
throw new GradleException(
"Redis upload sanitizer bundle marker is stale for ${directory.name}")
}
}
}
}
registerRedisEvidenceTask(
'redisStandaloneTest',
'redis-standalone',
'Runs real standalone Redis evidence. Docker/service absence and zero tests fail.')
registerRedisEvidenceTask(
'redisSecurityTest',
'redis-security',
'Runs Redis TLS, ACL, secret-redaction, and fail-closed security evidence.')
registerRedisEvidenceTask(
'redisSentinelTest',
'redis-sentinel',
'Runs the explicit Redis Sentinel topology evidence lane.')
registerRedisEvidenceTask(
'redisClusterTest',
'redis-cluster',
'Runs the explicit Redis Cluster topology evidence lane.')
registerRedisEvidenceTask(
'redisFaultTest',
'redis-fault',
'Runs bounded Redis outage, response-loss, memory, and recovery evidence.')
registerRedisEvidenceTask(
'redisCompatibilityTest',
'redis-compatibility',
'Runs pinned minimum/next/approved Redis compatibility evidence.')
registerRedisEvidenceTask(
'redisEfficiencyLeaseTest',
'redis-efficiency-lease',
'Runs non-fenced EFFICIENCY_ONLY lease standalone, security, fault, and compatibility qualification.')
def redisCardTags = [
redisCacheCapabilityTest : 'card-redis-cache',
redisRateLimitCapabilityTest : 'card-redis-edge-rate-limit',
redisIdempotencyCapabilityTest : 'card-redis-request-replay-idempotency',
redisSoftLeaseCapabilityTest : 'card-redis-cache-refresh-soft-lease',
redisFencedCoordinationCapabilityTest: 'card-redis-fenced-coordination',
redisSessionCapabilityTest : 'card-redis-session'
]
redisCardTags.each { String taskName, String cardTag ->
registerRedisEvidenceTask(
taskName,
cardTag,
"Runs all real-service evidence owned by Redis capability card ${cardTag}.")
}
def redisEvidenceTags = [
Standalone : 'redis-standalone',
Security : 'redis-security',
Sentinel : 'redis-sentinel',
Cluster : 'redis-cluster',
Fault : 'redis-fault',
Compatibility: 'redis-compatibility'
]
def redisCardTaskStems = [
Cache : 'card-redis-cache',
RateLimit : 'card-redis-edge-rate-limit',
Idempotency : 'card-redis-request-replay-idempotency',
SoftLease : 'card-redis-cache-refresh-soft-lease',
FencedCoordination: 'card-redis-fenced-coordination',
Session : 'card-redis-session'
]
redisCardTaskStems.each { String cardStem, String cardTag ->
redisEvidenceTags.each { String evidenceStem, String evidenceTag ->
registerRedisEvidenceTask(
"redis${cardStem}${evidenceStem}EvidenceTest",
"${cardTag} & ${evidenceTag}",
"Runs ${evidenceTag} evidence owned only by ${cardTag}.")
}
}
tasks.named('check') {
dependsOn tasks.named('redisStandaloneTest')
}
def redisLabContractDirectory = rootProject.file('../infra/redis-lab')
def redisLabContractTest = tasks.register('redisLabContractTest', Exec) {
group = 'verification'
description = 'Runs the VM-free Redis lab lifecycle and host-isolation contract with fake commands.'
workingDir rootProject.projectDir
executable 'bash'
args new File(redisLabContractDirectory, 'test/redis-lab-contract.sh').absolutePath
inputs.files(
new File(redisLabContractDirectory, 'versions.env'),
new File(redisLabContractDirectory, 'bin/redis-lab'),
new File(redisLabContractDirectory, 'cloud-init/node.yaml'),
new File(redisLabContractDirectory, 'lib/render-kubeconfig.awk'),
fileTree(new File(redisLabContractDirectory, 'test/fixtures')) {
include '**/*'
},
new File(redisLabContractDirectory, 'test/redis-lab-contract.sh'))
outputs.upToDateWhen { false }
}
tasks.named('check') {
dependsOn redisLabContractTest
}
+139 -118
View File
@@ -1,165 +1,186 @@
# This is a Gradle generated file for dependency locking. # This is a Gradle generated file for dependency locking.
# 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=redisTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.docker-java:docker-java-api:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath com.github.spotbugs:spotbugs-annotations:4.8.6=redisTestCompileClasspath,testCompileClasspath
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.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath com.google.code.findbugs:jsr305:3.0.2=checkstyle,redisTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath com.google.errorprone:error_prone_annotations:2.38.0=redisTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor com.google.guava:guava:33.5.0-jre=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.vaadin.external.google:android-json:0.0.20131108.vaadin1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=redisTestCompileClasspath,redisTestRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-logging:commons-logging:1.3.5=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
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,redisTestAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-core:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-buffer:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-base:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-handler:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-resolver:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-transport:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.8.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.18.1=redisTestCompileClasspath,redisTestRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs org.apache.commons:commons-compress:1.28.0=redisTestCompileClasspath,redisTestRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,redisTestCompileClasspath,redisTestRuntimeClasspath,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.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=spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-api:2.25.2=redisTestCompileClasspath,redisTestRuntimeClasspath,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=testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-to-slf4j:2.25.2=redisTestCompileClasspath,redisTestRuntimeClasspath,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
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle 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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
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=redisTestCompileClasspath,testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath org.assertj:assertj-core:3.27.6=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.jetbrains:annotations:17.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,redisTestAnnotationProcessor,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath org.junit.platform:junit-platform-commons:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath org.junit.platform:junit-platform-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.platform:junit-platform-launcher:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath org.latencyutils:LatencyUtils:2.0.3=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=redisTestRuntimeClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath org.opentest4j:opentest4j:1.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath org.osgi:org.osgi.resource:1.0.0=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=redisTestCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs 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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.pcollections:pcollections:4.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reactivestreams:reactive-streams:1.0.4=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,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.rnorth.duct-tape:duct-tape:1.0.8=redisTestCompileClasspath,redisTestRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.17=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,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,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor 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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-restclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-resttestclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-servlet:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-web-server:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.data:spring-data-commons:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.data:spring-data-keyvalue:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.data:spring-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.session:spring-session-core:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-aop:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-context-support:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-oxm:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath
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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty= empty=
@@ -0,0 +1,231 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.LongSupplier;
/** One daemon worker with storage bounded by the finite active Redis role count. */
final class BoundedRedisSentinelRefreshWorker implements RedisSentinelRefreshWorker {
private final Object monitor = new Object();
private final int capacity;
private final ArrayDeque<Runnable> immediateTasks;
private final List<RecurringTask> recurringTasks;
private final Thread worker;
private final LongSupplier nanoTime;
private boolean closed;
private boolean preferDueRecurring;
BoundedRedisSentinelRefreshWorker(int capacity, String threadName) {
this(capacity, threadName, System::nanoTime);
}
BoundedRedisSentinelRefreshWorker(int capacity, String threadName, LongSupplier nanoTime) {
if (capacity < 1) {
throw new IllegalArgumentException("Redis Sentinel worker capacity must be positive");
}
this.capacity = capacity;
this.immediateTasks = new ArrayDeque<>(capacity);
this.recurringTasks = new ArrayList<>(capacity);
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
this.worker =
Thread.ofPlatform().daemon(true).name(requireText(threadName)).unstarted(this::runWorker);
this.worker.start();
}
@Override
public Cancellable scheduleWithFixedDelay(Runnable task, Duration delay) {
Objects.requireNonNull(task, "task must be non-null");
long delayNanos = positiveNanos(delay);
RecurringTask recurring =
new RecurringTask(task, delayNanos, nanoTime.getAsLong() + delayNanos);
synchronized (monitor) {
ensureOpen();
if (recurringTasks.size() >= capacity) {
throw new IllegalStateException("Redis Sentinel recurring task capacity is exhausted");
}
recurringTasks.add(recurring);
monitor.notifyAll();
}
return () -> cancel(recurring);
}
@Override
public boolean execute(Runnable task) {
Objects.requireNonNull(task, "task must be non-null");
synchronized (monitor) {
if (closed || immediateTasks.size() >= capacity) {
return false;
}
immediateTasks.addLast(task);
monitor.notifyAll();
return true;
}
}
@Override
public void shutdown(Duration timeout) {
long timeoutNanos = positiveNanos(timeout);
synchronized (monitor) {
if (!closed) {
closed = true;
recurringTasks.forEach(task -> task.cancelled = true);
recurringTasks.clear();
immediateTasks.clear();
monitor.notifyAll();
}
}
worker.interrupt();
if (Thread.currentThread() == worker) {
return;
}
try {
long millis = Math.max(1, Math.min(Long.MAX_VALUE, timeoutNanos / 1_000_000L));
worker.join(millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
private void runWorker() {
while (true) {
Work work;
try {
work = awaitWork();
} catch (InterruptedException interrupted) {
if (isClosed()) {
Thread.currentThread().interrupt();
return;
}
continue;
}
if (work == null) {
return;
}
try {
work.task.run();
} catch (RuntimeException ignored) {
// Refresh failures are deliberately contained and rendered only through sanitized health.
} finally {
if (work.recurring != null) {
reschedule(work.recurring);
}
}
}
}
private Work awaitWork() throws InterruptedException {
synchronized (monitor) {
while (!closed) {
if (!preferDueRecurring) {
Runnable immediate = immediateTasks.pollFirst();
if (immediate != null) {
preferDueRecurring = true;
return new Work(immediate, null);
}
}
long now = nanoTime.getAsLong();
RecurringTask due = null;
long waitNanos = Long.MAX_VALUE;
for (RecurringTask task : recurringTasks) {
if (task.cancelled || task.running) {
continue;
}
long remaining = task.nextRunNanos - now;
if (remaining <= 0) {
due = task;
break;
}
waitNanos = Math.min(waitNanos, remaining);
}
if (due != null) {
due.running = true;
preferDueRecurring = false;
return new Work(due.task, due);
}
Runnable immediate = immediateTasks.pollFirst();
if (immediate != null) {
preferDueRecurring = true;
return new Work(immediate, null);
}
if (waitNanos == Long.MAX_VALUE) {
monitor.wait();
} else {
long millis = waitNanos / 1_000_000L;
int nanos = (int) (waitNanos % 1_000_000L);
monitor.wait(millis, nanos);
}
}
return null;
}
}
private void reschedule(RecurringTask task) {
synchronized (monitor) {
task.running = false;
if (!closed && !task.cancelled) {
task.nextRunNanos = nanoTime.getAsLong() + task.delayNanos;
}
monitor.notifyAll();
}
}
private void cancel(RecurringTask task) {
synchronized (monitor) {
task.cancelled = true;
recurringTasks.remove(task);
monitor.notifyAll();
}
}
private boolean isClosed() {
synchronized (monitor) {
return closed;
}
}
private void ensureOpen() {
if (closed) {
throw new IllegalStateException("Redis Sentinel refresh worker is closed");
}
}
private static long positiveNanos(Duration duration) {
Objects.requireNonNull(duration, "duration must be non-null");
if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("Redis Sentinel worker duration must be positive");
}
try {
return duration.toNanos();
} catch (ArithmeticException overflow) {
return Long.MAX_VALUE;
}
}
private static String requireText(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Redis Sentinel worker name must be non-blank");
}
return value.trim();
}
private record Work(Runnable task, RecurringTask recurring) {}
private static final class RecurringTask {
private final Runnable task;
private final long delayNanos;
private long nextRunNanos;
private boolean running;
private boolean cancelled;
private RecurringTask(Runnable task, long delayNanos, long nextRunNanos) {
this.task = task;
this.delayNanos = delayNanos;
this.nextRunNanos = nextRunNanos;
}
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.RedisChannelHandler;
import io.lettuce.core.RedisConnectionStateListener;
import io.lettuce.core.pubsub.RedisPubSubAdapter;
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Managed standalone Redis Pub/Sub listener for best-effort cache invalidation hints. */
final class LettuceRedisCacheInvalidationSubscription implements AutoCloseable {
private final StatefulRedisPubSubConnection<byte[], byte[]> connection;
private final RedisCacheInvalidationSubscriber subscriber;
private final AtomicBoolean closed = new AtomicBoolean();
private LettuceRedisCacheInvalidationSubscription(
StatefulRedisPubSubConnection<byte[], byte[]> connection,
RedisCacheInvalidationSubscriber subscriber) {
this.connection = connection;
this.subscriber = subscriber;
}
static LettuceRedisCacheInvalidationSubscription subscribe(
LettuceRedisRuntime runtime,
String channel,
RedisCacheInvalidationMessage.Codec codec,
RedisCacheInvalidationSubscriber subscriber) {
Objects.requireNonNull(runtime, "runtime must be non-null");
Objects.requireNonNull(channel, "channel must be non-null");
Objects.requireNonNull(codec, "codec must be non-null");
Objects.requireNonNull(subscriber, "subscriber must be non-null");
byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII);
StatefulRedisPubSubConnection<byte[], byte[]> connection =
runtime.openInvalidationSubscription();
connection.addListener(
new RedisPubSubAdapter<>() {
@Override
public void message(byte[] actualChannel, byte[] message) {
if (!Arrays.equals(channelBytes, actualChannel) || message == null) {
return;
}
codec
.decode(new String(message, StandardCharsets.US_ASCII))
.ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage);
}
});
connection.addListener(
new RedisConnectionStateListener() {
@Override
public void onRedisConnected(
RedisChannelHandler<?, ?> connection, SocketAddress remoteAddress) {
// A preceding disconnect already forced L1 flush and generation recheck.
}
@Override
public void onRedisDisconnected(RedisChannelHandler<?, ?> connection) {
subscriber.onDisconnected();
}
});
try {
connection.sync().subscribe(channelBytes);
return new LettuceRedisCacheInvalidationSubscription(connection, subscriber);
} catch (RuntimeException exception) {
connection.close();
throw exception;
}
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
try {
connection.close();
} finally {
subscriber.onDisconnected();
}
}
}
}
@@ -0,0 +1,228 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import io.lettuce.core.AbstractRedisClient;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.ConnectionFuture;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulConnection;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.RedisClusterClient;
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
import io.lettuce.core.codec.ByteArrayCodec;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
/** Opens, probes, and owns topology-native Lettuce clients and connections. */
final class LettuceRedisNativeClientFactory implements RedisNativeClientFactory {
interface LifecycleObserver {
LifecycleObserver NOOP = new LifecycleObserver() {};
default void clientCreated() {}
default void connectionClosed() {}
default void clientClosed() {}
}
private final LifecycleObserver observer;
LettuceRedisNativeClientFactory() {
this(LifecycleObserver.NOOP);
}
LettuceRedisNativeClientFactory(LifecycleObserver observer) {
this.observer = Objects.requireNonNull(observer, "observer must be non-null");
}
@Override
public RedisNativeClientHandle openStandalone(
RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings) {
Objects.requireNonNull(uri, "uri must be non-null");
Objects.requireNonNull(options, "options must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
RedisClient client = RedisClient.create(uri);
observer.clientCreated();
StatefulRedisConnection<byte[], byte[]> connection = null;
try {
client.setOptions(options);
long deadline = deadline(settings.overallTimeout());
ConnectionFuture<StatefulRedisConnection<byte[], byte[]>> connect =
client.connectAsync(ByteArrayCodec.INSTANCE, uri);
connection =
await(
connect,
boundedByRemaining(settings.acquireTimeout(), deadline),
"Redis standalone connect");
connection.setTimeout(settings.commandTimeout());
await(
connection.async().ping(),
boundedByRemaining(settings.commandTimeout(), deadline),
"Redis standalone probe");
return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer);
} catch (RuntimeException exception) {
closeFailed(client, connection, settings.shutdownTimeout(), observer, List.of(uri));
throw sanitizedConnectFailure(exception);
}
}
@Override
public RedisNativeClientHandle openCluster(
List<RedisURI> seedUris, ClusterClientOptions options, RedisClientRuntimeSettings settings) {
List<RedisURI> uris =
List.copyOf(Objects.requireNonNull(seedUris, "seedUris must be non-null"));
Objects.requireNonNull(options, "options must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
RedisClusterClient client = RedisClusterClient.create(uris);
observer.clientCreated();
StatefulRedisClusterConnection<byte[], byte[]> connection = null;
try {
client.setOptions(options);
long deadline = deadline(settings.overallTimeout());
java.util.concurrent.CompletableFuture<StatefulRedisClusterConnection<byte[], byte[]>>
connect = client.connectAsync(ByteArrayCodec.INSTANCE);
connection =
await(
connect,
boundedByRemaining(settings.acquireTimeout(), deadline),
"Redis Cluster connect");
connection.setTimeout(settings.commandTimeout());
await(
connection.async().ping(),
boundedByRemaining(settings.commandTimeout(), deadline),
"Redis Cluster probe");
return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer);
} catch (RuntimeException exception) {
closeFailed(client, connection, settings.shutdownTimeout(), observer, uris);
throw sanitizedConnectFailure(exception);
}
}
private static long deadline(Duration overallTimeout) {
long timeoutNanos = overallTimeout.toNanos();
long now = System.nanoTime();
return now > Long.MAX_VALUE - timeoutNanos ? Long.MAX_VALUE : now + timeoutNanos;
}
private static Duration boundedByRemaining(Duration operationTimeout, long deadline) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) {
throw new IllegalStateException("Redis overall connect deadline expired");
}
Duration remainingDuration = Duration.ofNanos(remaining);
return operationTimeout.compareTo(remainingDuration) < 0 ? operationTimeout : remainingDuration;
}
private static <T> T await(
java.util.concurrent.Future<T> future, Duration timeout, String operation) {
try {
return future.get(timeout.toNanos(), TimeUnit.NANOSECONDS);
} catch (InterruptedException exception) {
future.cancel(true);
Thread.currentThread().interrupt();
throw new IllegalStateException(operation + " was interrupted");
} catch (TimeoutException exception) {
future.cancel(true);
throw new IllegalStateException(operation + " exceeded its bounded timeout");
} catch (ExecutionException exception) {
throw new IllegalStateException(operation + " failed");
}
}
private static IllegalStateException sanitizedConnectFailure(RuntimeException ignored) {
return new IllegalStateException("Redis connect or probe failed within its bounded deadline");
}
private static void closeFailed(
AbstractRedisClient client,
StatefulConnection<?, ?> connection,
Duration shutdownTimeout,
LifecycleObserver observer,
List<RedisURI> uris) {
try {
closeConnection(connection, observer);
} finally {
try {
client.shutdown(Duration.ZERO, shutdownTimeout);
} finally {
observer.clientClosed();
uris.forEach(LettuceRedisNativeClientFactory::destroyCredentials);
}
}
}
private static void closeConnection(
StatefulConnection<?, ?> connection, LifecycleObserver observer) {
if (connection == null) {
return;
}
try {
connection.close();
} finally {
observer.connectionClosed();
}
}
private static void destroyCredentials(RedisURI uri) {
if (uri.getCredentialsProvider() instanceof javax.security.auth.Destroyable destroyable) {
try {
destroyable.destroy();
} catch (javax.security.auth.DestroyFailedException ignored) {
// The adapter-owned providers do not throw; remain fail-safe for alternate implementations.
}
}
}
private static final class LettuceHandle implements RedisNativeClientHandle {
private final AbstractRedisClient client;
private final StatefulConnection<?, ?> connection;
private final Duration configuredShutdownTimeout;
private final LifecycleObserver observer;
private final AtomicBoolean closed = new AtomicBoolean();
private LettuceHandle(
AbstractRedisClient client,
StatefulConnection<?, ?> connection,
Duration configuredShutdownTimeout,
LifecycleObserver observer) {
this.client = client;
this.connection = connection;
this.configuredShutdownTimeout = configuredShutdownTimeout;
this.observer = observer;
}
@Override
public Class<?> nativeClientType() {
return client.getClass();
}
@Override
public void close(Duration timeout) {
Objects.requireNonNull(timeout, "timeout must be non-null");
if (!timeout.equals(configuredShutdownTimeout)) {
throw new IllegalArgumentException("Redis shutdown timeout differs from runtime settings");
}
if (closed.compareAndSet(false, true)) {
try {
closeConnection(connection, observer);
} finally {
try {
client.shutdown(Duration.ZERO, timeout);
} finally {
observer.clientClosed();
}
}
}
}
}
}
@@ -14,10 +14,12 @@ import io.lettuce.core.TimeoutOptions;
import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands; import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.codec.ByteArrayCodec; import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import java.net.SocketAddress; import java.net.SocketAddress;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier; import java.util.function.Supplier;
@@ -26,19 +28,7 @@ import java.util.function.Supplier;
final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, AutoCloseable { final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, AutoCloseable {
private static final String VALUE_TOO_LARGE_ERROR = "CA_VALUE_TOO_LARGE"; private static final String VALUE_TOO_LARGE_ERROR = "CA_VALUE_TOO_LARGE";
private static final byte[] BOUNDED_GET_SCRIPT = private static final RedisProgramCatalog FOUNDATION_CATALOG = RedisProgramCatalog.foundation();
"""
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 io.lettuce.core.RedisClient client;
private final StatefulRedisConnection<byte[], byte[]> connection; private final StatefulRedisConnection<byte[], byte[]> connection;
@@ -54,17 +44,17 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
private LettuceRedisRuntime( private LettuceRedisRuntime(
io.lettuce.core.RedisClient client, io.lettuce.core.RedisClient client,
StatefulRedisConnection<byte[], byte[]> connection, StatefulRedisConnection<byte[], byte[]> connection,
RedisRuntimeSettings settings) { RedisConnectionProfile settings) {
this.client = client; this.client = client;
this.connection = connection; this.connection = connection;
this.commands = connection.sync(); this.commands = connection.sync();
this.legacyTtl = settings.positiveTtl(); this.legacyTtl = settings.legacyTtl();
this.shutdownTimeout = settings.commandTimeout(); this.shutdownTimeout = settings.commandTimeout();
this.commandAdmission = this.commandAdmission =
new RedisCommandAdmission( new RedisCommandAdmission(
settings.maximumQueuedCommands(), settings.maximumInFlightBytes()); settings.maximumQueuedCommands(), settings.maximumInFlightBytes());
this.maximumReadableValueBytes = settings.maximumValueBytes() + 1024 + 32; this.maximumReadableValueBytes = settings.maximumReadableValueBytes();
this.maximumCommandBytes = settings.maximumValueBytes() + 2048; this.maximumCommandBytes = settings.maximumCommandBytes();
connection.addListener( connection.addListener(
new RedisConnectionStateListener() { new RedisConnectionStateListener() {
@Override @Override
@@ -81,6 +71,14 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
} }
static LettuceRedisRuntime connect(RedisRuntimeSettings settings) { static LettuceRedisRuntime connect(RedisRuntimeSettings settings) {
return connect(RedisConnectionProfile.cache(settings));
}
static LettuceRedisRuntime connect(RedisLegacyStandaloneSettings settings) {
return connect(RedisConnectionProfile.rateLimit(settings));
}
private static LettuceRedisRuntime connect(RedisConnectionProfile settings) {
RedisURI uri = redisUri(settings); RedisURI uri = redisUri(settings);
io.lettuce.core.RedisClient client = io.lettuce.core.RedisClient.create(uri); io.lettuce.core.RedisClient client = io.lettuce.core.RedisClient.create(uri);
client.setOptions(clientOptions(settings)); client.setOptions(clientOptions(settings));
@@ -95,6 +93,10 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
} }
static RedisURI redisUri(RedisRuntimeSettings settings) { static RedisURI redisUri(RedisRuntimeSettings settings) {
return redisUri(RedisConnectionProfile.cache(settings));
}
private static RedisURI redisUri(RedisConnectionProfile settings) {
RedisURI.Builder builder = RedisURI.Builder builder =
RedisURI.Builder.redis(settings.host(), settings.port()) RedisURI.Builder.redis(settings.host(), settings.port())
.withTimeout(settings.commandTimeout()); .withTimeout(settings.commandTimeout());
@@ -105,6 +107,10 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
} }
static ClientOptions clientOptions(RedisRuntimeSettings settings) { static ClientOptions clientOptions(RedisRuntimeSettings settings) {
return clientOptions(RedisConnectionProfile.cache(settings));
}
private static ClientOptions clientOptions(RedisConnectionProfile settings) {
return ClientOptions.builder() return ClientOptions.builder()
.autoReconnect(true) .autoReconnect(true)
.replayFilter(ignored -> true) .replayFilter(ignored -> true)
@@ -116,7 +122,7 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
@Override @Override
public Optional<String> read(String key) { public Optional<String> read(String key) {
byte[] value = get(key.getBytes(StandardCharsets.UTF_8)); byte[] value = get(RedisPhysicalKey.owned(new LegacyKeyMaterial(key)));
return value == null return value == null
? Optional.empty() ? Optional.empty()
: Optional.of(new String(value, StandardCharsets.UTF_8)); : Optional.of(new String(value, StandardCharsets.UTF_8));
@@ -124,84 +130,109 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
@Override @Override
public void write(String key, String value) { public void write(String key, String value) {
set(key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8), legacyTtl); set(
RedisPhysicalKey.owned(new LegacyKeyMaterial(key)),
RedisBinaryValue.utf8(value),
legacyTtl);
} }
@Override @Override
public byte[] get(byte[] key) { public byte[] get(RedisPhysicalKey key) {
byte[] limit = Integer.toString(maximumReadableValueBytes).getBytes(StandardCharsets.US_ASCII); RedisCatalogProgramInvocation invocation =
try { FOUNDATION_CATALOG.boundedGetInvocation(key, maximumReadableValueBytes);
byte[] value = byte[] value = RedisScriptRecovery.evalReadOnlyValue(this, invocation);
execute( return value == null ? null : value.clone();
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 @Override
public void set(byte[] key, byte[] value, Duration timeToLive) { public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {
byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key);
byte[] encodedValue = value.copyEncoded();
String result = String result =
execute( execute(
true, true,
reservationBytes(64, List.of(key, value)), reservationBytes(64, List.of(encodedKey, encodedValue)),
() -> () ->
commands.set( commands.set(encodedKey, encodedValue, SetArgs.Builder.px(timeToLive.toMillis())));
key.clone(), value.clone(), SetArgs.Builder.px(timeToLive.toMillis())));
if (!"OK".equals(result)) { if (!"OK".equals(result)) {
throw new IllegalStateException("Redis SET did not acknowledge the mutation"); throw new IllegalStateException("Redis SET did not acknowledge the mutation");
} }
} }
@Override @Override
public long delete(byte[] key) { public long delete(RedisPhysicalKey key) {
return execute(true, reservationBytes(32, List.of(key)), () -> commands.del(key.clone())); byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key);
return execute(true, reservationBytes(32, List.of(encodedKey)), () -> commands.del(encodedKey));
} }
@Override @Override
public byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments) { public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) {
boolean mutation =
invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_VALUE
&& invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI;
ScriptOutputType outputType =
invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI
|| invocation.replyShape()
== RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI
? ScriptOutputType.MULTI
: ScriptOutputType.VALUE;
try { try {
return execute( Object result =
true, execute(
reservationBytes(256, keys, arguments), mutation,
() -> Math.max(256, invocation.encodedBytes()),
commands.evalsha( () ->
sha1, commands.evalsha(
ScriptOutputType.VALUE, RedisScriptRecovery.sha1(
keys.toArray(byte[][]::new), RedisCatalogProgramInvocation.WireCodec.exactScript(invocation)),
arguments.toArray(byte[][]::new))); outputType,
RedisCatalogProgramInvocation.WireCodec.keysArray(invocation),
RedisCatalogProgramInvocation.WireCodec.argumentsArray(invocation)));
if (outputType == ScriptOutputType.MULTI) {
@SuppressWarnings("unchecked")
List<byte[]> fields = (List<byte[]>) result;
return RedisCatalogProgramReply.multi(defensiveReply(fields));
}
return RedisCatalogProgramReply.value((byte[]) result);
} catch (io.lettuce.core.RedisNoScriptException exception) { } catch (io.lettuce.core.RedisNoScriptException exception) {
throw new RedisNoScriptException(); throw new RedisNoScriptException();
} catch (RedisCommandExecutionException exception) {
if (exception.getMessage() != null
&& exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) {
throw new RedisValueTooLargeException();
}
throw commandFailure(
mutation,
mutation
? "Redis Lua program execution failed"
: "Redis read-only Lua program execution failed",
exception);
} }
} }
@Override @Override
public byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments) { public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) {
return execute( byte[] script = RedisCatalogProgramInvocation.WireCodec.exactScript(invocation);
try {
return execute(
true, reservationBytes(64, List.of(script)), () -> commands.scriptLoad(script.clone()));
} catch (RedisCommandExecutionException exception) {
throw commandFailure(true, "Redis script load failed", exception);
}
}
void publishInvalidation(String channel, String message) {
byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII);
byte[] messageBytes = message.getBytes(StandardCharsets.US_ASCII);
execute(
true, true,
reservationBytes(256, List.of(script), keys, arguments), reservationBytes(64, List.of(channelBytes, messageBytes)),
() -> () -> commands.publish(channelBytes, messageBytes));
commands.eval( }
script.clone(),
ScriptOutputType.VALUE, StatefulRedisPubSubConnection<byte[], byte[]> openInvalidationSubscription() {
keys.toArray(byte[][]::new), ensureOpen();
arguments.toArray(byte[][]::new))); return client.connectPubSub(ByteArrayCodec.INSTANCE);
} }
@Override @Override
@@ -252,7 +283,7 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
throw exception; throw exception;
} catch (RedisCommandInterruptedException exception) { } catch (RedisCommandInterruptedException exception) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw exception; throw commandFailure(mutation, "Redis command was interrupted", exception);
} catch (RedisCommandTimeoutException exception) { } catch (RedisCommandTimeoutException exception) {
throw commandFailure(mutation, "Redis command timed out", exception); throw commandFailure(mutation, "Redis command timed out", exception);
} catch (RedisConnectionException exception) { } catch (RedisConnectionException exception) {
@@ -273,6 +304,13 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
cause); cause);
} }
private static List<byte[]> defensiveReply(List<byte[]> result) {
if (result == null) {
return null;
}
return result.stream().map(value -> value == null ? null : value.clone()).toList();
}
@SafeVarargs @SafeVarargs
private static int reservationBytes(int responseBytes, List<byte[]>... groups) { private static int reservationBytes(int responseBytes, List<byte[]>... groups) {
long total = Math.max(1, responseBytes); long total = Math.max(1, responseBytes);
@@ -289,4 +327,20 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
} }
return (int) total; return (int) total;
} }
static final class LegacyKeyMaterial implements RedisOwnedPhysicalKeyMaterial {
private final byte[] encoded;
private LegacyKeyMaterial(String key) {
this.encoded =
Objects.requireNonNull(key, "legacy key must be non-null")
.getBytes(StandardCharsets.UTF_8);
}
@Override
public byte[] copyEncodedKey() {
return encoded.clone();
}
}
} }
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.CacheObservationEvent;
import dev.caskeleton.application.cache.CacheObservationPort;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/** Micrometer rendering for the framework-free cache observation boundary. */
final class MicrometerCacheObservationPort implements CacheObservationPort {
private final MeterRegistry registry;
private final Set<String> cacheNames;
MicrometerCacheObservationPort(MeterRegistry registry, Set<String> cacheNames) {
this.registry = Objects.requireNonNull(registry, "registry must be non-null");
this.cacheNames = Set.copyOf(Objects.requireNonNull(cacheNames, "cacheNames must be non-null"));
if (this.cacheNames.isEmpty() || this.cacheNames.size() > 50) {
throw new IllegalArgumentException("cacheNames must contain 1..50 startup-registered names");
}
if (this.cacheNames.stream().anyMatch(name -> name == null || name.isBlank())) {
throw new IllegalArgumentException("cacheNames must contain non-blank names");
}
}
@Override
public void observe(CacheObservationEvent event) {
Objects.requireNonNull(event, "event must be non-null");
String cacheName =
event instanceof CacheObservationEvent.Lookup lookup
? lookup.cacheName()
: ((CacheObservationEvent.LocalMaintenance) event).cacheName();
if (!cacheNames.contains(cacheName)) {
throw new IllegalArgumentException("cacheName is not in the startup allowlist");
}
if (event instanceof CacheObservationEvent.Lookup lookup) {
observeLookup(lookup);
return;
}
CacheObservationEvent.LocalMaintenance maintenance =
(CacheObservationEvent.LocalMaintenance) event;
Counter.builder("cache.local.maintenance.total")
.tag("cache_name", maintenance.cacheName())
.tag("event", maintenanceEvent(maintenance))
.register(registry)
.increment();
}
private void observeLookup(CacheObservationEvent.Lookup lookup) {
if (lookup.tier() != CacheObservationEvent.Tier.LOCAL_L1) {
return;
}
Counter.builder("cache.local.requests.total")
.tag("cache_name", lookup.cacheName())
.tag("result", lower(lookup.result()))
.register(registry)
.increment();
if (lookup.result() == CacheObservationEvent.LookupResult.HIT) {
Timer.builder("cache.local.entry.age.seconds")
.tag("cache_name", lookup.cacheName())
.register(registry)
.record(lookup.entryAge());
}
}
private static String lower(Enum<?> value) {
return value.name().toLowerCase(Locale.ROOT);
}
private static String maintenanceEvent(CacheObservationEvent.LocalMaintenance event) {
if (event.action() == CacheObservationEvent.MaintenanceAction.EVICT) {
return "evict_" + lower(event.cause());
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED) {
return "reconcile_generation_changed";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.RECONCILE) {
return event.result() == CacheObservationEvent.MaintenanceResult.ERROR
? "reconcile_error"
: "reconcile_unchanged";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_DISCONNECTED) {
return "subscriber_disconnected";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_OVERFLOW) {
return "subscriber_overflow";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.MALFORMED_MESSAGE) {
return "subscriber_malformed";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT
&& event.result() == CacheObservationEvent.MaintenanceResult.FLUSHED) {
return "flush_invalidation";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT) {
return event.result() == CacheObservationEvent.MaintenanceResult.SUCCESS
? "subscriber_publish_success"
: "subscriber_publish_error";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.INVALIDATION) {
return "flush_invalidation";
}
return "other";
}
}
@@ -0,0 +1,120 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/** Renders the closed Redis capability event model to its six registry-approved meters. */
final class MicrometerRedisCapabilityObservationPort implements RedisCapabilityObservationPort {
private final MeterRegistry registry;
private final ConcurrentMap<
RedisCapabilityObservationEvent.Role, AtomicReference<InFlightSnapshot>>
inFlight = new ConcurrentHashMap<>();
MicrometerRedisCapabilityObservationPort(MeterRegistry registry) {
this.registry = Objects.requireNonNull(registry, "registry must be non-null");
}
@Override
public void observe(RedisCapabilityObservationEvent.Event event) {
Objects.requireNonNull(event, "event must be non-null");
switch (event) {
case RedisCapabilityObservationEvent.OperationCompleted operation ->
observeOperation(operation);
case RedisCapabilityObservationEvent.AdmissionChanged admission ->
observeAdmission(admission);
case RedisCapabilityObservationEvent.ReadinessObserved readiness ->
observeReadiness(readiness);
case RedisCapabilityObservationEvent.LifecycleDrainCompleted lifecycle ->
observeLifecycle(lifecycle);
}
}
private void observeOperation(RedisCapabilityObservationEvent.OperationCompleted event) {
Counter.builder("redis.capability.operations.total")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"operation", lower(event.operation()),
"redis_outcome", lower(event.outcome()),
"certainty", lower(event.certainty()))
.register(registry)
.increment();
Timer.builder("redis.capability.duration.seconds")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"operation", lower(event.operation()),
"redis_outcome", lower(event.outcome()))
.register(registry)
.record(event.durationNanos(), TimeUnit.NANOSECONDS);
}
private void observeAdmission(RedisCapabilityObservationEvent.AdmissionChanged event) {
if (event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED
|| event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_CLOSED) {
Counter.builder("redis.capability.admission.rejected.total")
.tags("role", lower(event.role()), "admission", lower(event.admission()))
.register(registry)
.increment();
}
snapshot(event.role()).set(new InFlightSnapshot(event.state(), event.inFlightCommands()));
}
private void observeReadiness(RedisCapabilityObservationEvent.ReadinessObserved event) {
Counter.builder("redis.capability.readiness.total")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"state", lower(event.state()),
"reason", lower(event.reason()),
"requirement", lower(event.requirement()))
.register(registry)
.increment();
}
private void observeLifecycle(RedisCapabilityObservationEvent.LifecycleDrainCompleted event) {
Counter.builder("redis.capability.lifecycle.drain.total")
.tags("role", lower(event.role()), "drain_outcome", lower(event.drainOutcome()))
.register(registry)
.increment();
}
private static String lower(Enum<?> value) {
return value.name().toLowerCase(Locale.ROOT);
}
private AtomicReference<InFlightSnapshot> snapshot(RedisCapabilityObservationEvent.Role role) {
return inFlight.computeIfAbsent(
role,
ignored -> {
AtomicReference<InFlightSnapshot> value =
new AtomicReference<>(
new InFlightSnapshot(RedisCapabilityObservationEvent.InFlightState.IDLE, 0));
for (RedisCapabilityObservationEvent.InFlightState state :
RedisCapabilityObservationEvent.InFlightState.values()) {
Gauge.builder(
"redis.capability.inflight.total",
value,
reference -> {
InFlightSnapshot current = reference.get();
return current.state() == state ? current.commands() : 0;
})
.tags("role", lower(role), "state", lower(state))
.register(registry);
}
return value;
});
}
private record InFlightSnapshot(
RedisCapabilityObservationEvent.InFlightState state, int commands) {}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.adapter.outbound.cache.redis;
enum NoOpRedisCapabilityObservationPort implements RedisCapabilityObservationPort {
INSTANCE;
static RedisCapabilityObservationPort instance() {
return INSTANCE;
}
@Override
public void observe(RedisCapabilityObservationEvent.Event event) {
// Intentionally disabled.
}
}
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@@ -10,8 +11,9 @@ final class RedisAtomicPrimitives {
private static final int MAXIMUM_OWNER_BYTES = 128; private static final int MAXIMUM_OWNER_BYTES = 128;
private static final int MAXIMUM_OPERATION_ID_BYTES = 128; private static final int MAXIMUM_OPERATION_ID_BYTES = 128;
private static final int MAXIMUM_VALUE_BYTES = 1_048_576; private static final int MAXIMUM_VALUE_BYTES = 16_778_272;
private static final long MAXIMUM_TTL_MILLIS = Duration.ofDays(30).toMillis(); private static final long MAXIMUM_TTL_MILLIS = Duration.ofDays(30).toMillis();
private static final long MAXIMUM_CONTROL_TTL_MILLIS = Duration.ofDays(31).toMillis();
private final RedisProgramCatalog catalog; private final RedisProgramCatalog catalog;
private final RedisProgramExecutor executor; private final RedisProgramExecutor executor;
@@ -56,12 +58,110 @@ final class RedisAtomicPrimitives {
return parse(RedisProgramId.SET_IF_ABSENT_WITH_TTL, status, SetIfAbsentResult.class); return parse(RedisProgramId.SET_IF_ABSENT_WITH_TTL, status, SetIfAbsentResult.class);
} }
ReplaceIfObservedResult replaceIfObservedWithTtl(
String key, String observationToken, byte[] value, Duration timeToLive, String operationId) {
byte[] keyBytes = key(key);
byte[] expectedDigest = observationDigest(observationToken);
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.REPLACE_IF_OBSERVED_WITH_TTL,
List.of(keyBytes),
List.of(expectedDigest, boundedValue, ttl, operation));
return parse(
RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, status, ReplaceIfObservedResult.class);
}
GenerationInitResult initializeGeneration(String key, String candidateGeneration) {
return initializeGeneration(key, candidateGeneration, Duration.ZERO);
}
GenerationInitResult initializeGeneration(
String key, String candidateGeneration, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] generation = identifier(candidateGeneration, "candidateGeneration");
byte[] ttl = controlTtl(timeToLive);
String status =
execute(RedisProgramId.REGION_GENERATION_INIT, List.of(keyBytes), List.of(generation, ttl));
return parse(RedisProgramId.REGION_GENERATION_INIT, status, GenerationInitResult.class);
}
GenerationBumpResult bumpGeneration(String key, String candidateGeneration, String operationId) {
return bumpGeneration(key, candidateGeneration, operationId, Duration.ZERO);
}
GenerationBumpResult bumpGeneration(
String key, String candidateGeneration, String operationId, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] generation = identifier(candidateGeneration, "candidateGeneration");
byte[] operation = identifier(operationId, "operationId");
byte[] ttl = controlTtl(timeToLive);
String status =
execute(
RedisProgramId.REGION_GENERATION_BUMP,
List.of(keyBytes),
List.of(generation, operation, ttl));
return parse(RedisProgramId.REGION_GENERATION_BUMP, status, GenerationBumpResult.class);
}
RefreshClaimResult claimRefreshLease(
String key, String ownerToken, String operationToken, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] owner = identifier(ownerToken, "ownerToken");
byte[] operation = identifier(operationToken, "operationToken");
byte[] ttl = refreshLeaseTtl(timeToLive);
String status =
execute(
RedisProgramId.CACHE_REFRESH_CLAIM, List.of(keyBytes), List.of(owner, operation, ttl));
return parse(RedisProgramId.CACHE_REFRESH_CLAIM, status, RefreshClaimResult.class);
}
private String execute(RedisProgramId id, List<byte[]> keys, List<byte[]> arguments) { private String execute(RedisProgramId id, List<byte[]> keys, List<byte[]> arguments) {
RedisProgramDescriptor descriptor = catalog.descriptor(id); RedisProgramDescriptor descriptor = catalog.descriptor(id);
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) { if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
throw new IllegalStateException("typed Redis program signature drift for " + id.externalId()); throw new IllegalStateException("typed Redis program signature drift for " + id.externalId());
} }
return executor.execute(descriptor, List.copyOf(keys), List.copyOf(arguments)); return executor.execute(catalog.capabilityInvocation(new ProgramMaterial(id, keys, arguments)));
}
static final class ProgramMaterial implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final List<byte[]> keys;
private final List<byte[]> arguments;
private ProgramMaterial(RedisProgramId programId, List<byte[]> keys, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.keys = keys.stream().map(byte[]::clone).toList();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.VALUE;
}
@Override
public List<byte[]> copyKeys() {
return keys.stream().map(byte[]::clone).toList();
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
} }
private static byte[] key(String key) { private static byte[] key(String key) {
@@ -84,6 +184,66 @@ final class RedisAtomicPrimitives {
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII); return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
} }
private static byte[] controlTtl(Duration timeToLive) {
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
long milliseconds;
try {
milliseconds = timeToLive.toMillis();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("control TTL exceeds supported range", exception);
}
if (milliseconds < 0 || milliseconds > MAXIMUM_CONTROL_TTL_MILLIS) {
throw new IllegalArgumentException(
"control TTL must be between 0 and " + MAXIMUM_CONTROL_TTL_MILLIS + " milliseconds");
}
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] refreshLeaseTtl(Duration timeToLive) {
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
long milliseconds;
try {
milliseconds = timeToLive.toMillis();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("refresh lease TTL exceeds supported range", exception);
}
long maximum = Duration.ofMinutes(5).toMillis();
if (milliseconds < 1 || milliseconds > maximum) {
throw new IllegalArgumentException(
"refresh lease TTL must be between 1 and " + maximum + " milliseconds");
}
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] observationDigest(String observationToken) {
Objects.requireNonNull(observationToken, "observationToken must be non-null");
byte[] digest;
try {
digest = Base64.getUrlDecoder().decode(observationToken);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("observationToken must be unpadded Base64URL", exception);
}
if (digest.length != 32
|| !Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(digest)
.equals(observationToken)) {
throw new IllegalArgumentException(
"observationToken must encode exactly one canonical SHA-256 digest");
}
return digest;
}
private static byte[] identifier(String value, String field) {
Objects.requireNonNull(value, field + " must be non-null");
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
if (bytes.length < 16 || bytes.length > 64 || !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(
field + " must be a Base64URL-safe identifier of 16..64 bytes");
}
return bytes;
}
private static byte[] bounded(byte[] value, int maximumBytes, String field) { private static byte[] bounded(byte[] value, int maximumBytes, String field) {
Objects.requireNonNull(value, field + " must be non-null"); Objects.requireNonNull(value, field + " must be non-null");
if (value.length < 1 || value.length > maximumBytes) { if (value.length < 1 || value.length > maximumBytes) {
@@ -123,4 +283,34 @@ final class RedisAtomicPrimitives {
WRONG_TYPE, WRONG_TYPE,
INVALID INVALID
} }
enum ReplaceIfObservedResult {
REPLACED,
ABSENT,
NOT_MATCHED,
WRONG_TYPE,
INVALID
}
enum GenerationInitResult {
INITIALIZED,
EXISTING,
WRONG_TYPE,
INVALID
}
enum GenerationBumpResult {
BUMPED,
ALREADY_APPLIED,
WRONG_TYPE,
INVALID
}
enum RefreshClaimResult {
CLAIMED,
ALREADY_OWNED,
CONTENDED,
WRONG_TYPE,
INVALID
}
} }
@@ -1,18 +1,13 @@
package dev.caskeleton.adapter.outbound.cache.redis; package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration; import java.time.Duration;
import java.util.List;
/** Minimal binary Redis command surface owned entirely by this adapter. */ /** Minimal binary Redis command surface owned entirely by this adapter. */
interface RedisBinaryCommands { interface RedisBinaryCommands extends RedisStructuredCommands {
byte[] get(byte[] key); byte[] get(RedisPhysicalKey key);
void set(byte[] key, byte[] value, Duration timeToLive); void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive);
long delete(byte[] key); long delete(RedisPhysicalKey key);
byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments);
byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments);
} }
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/** Opaque bounded adapter-private value crossing the command gateway. */
final class RedisBinaryValue {
private static final int MAXIMUM_VALUE_BYTES = 16_777_216;
private final byte[] encoded;
private RedisBinaryValue(byte[] encoded) {
Objects.requireNonNull(encoded, "Redis binary value must be non-null");
if (encoded.length < 1 || encoded.length > MAXIMUM_VALUE_BYTES) {
throw new IllegalArgumentException("Redis binary value is out of bounds");
}
this.encoded = encoded.clone();
}
static RedisBinaryValue encoded(byte[] encoded) {
return new RedisBinaryValue(encoded);
}
static RedisBinaryValue utf8(String encoded) {
Objects.requireNonNull(encoded, "Redis binary value must be non-null");
return new RedisBinaryValue(encoded.getBytes(StandardCharsets.UTF_8));
}
int encodedLength() {
return encoded.length;
}
byte[] copyEncoded() {
return encoded.clone();
}
@Override
public String toString() {
return "RedisBinaryValue[redacted]";
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Descriptor-owned byte offset for BITCOUNT ranges (Redis BITCOUNT is byte-indexed). */
record RedisBitmapByteOffset(long value) {
RedisBitmapByteOffset {
if (value < 0 || value >= 1_048_576) {
throw new IllegalArgumentException("bitmap byte offset exceeds fixed descriptor domain");
}
}
static RedisBitmapByteOffset of(long value) {
return new RedisBitmapByteOffset(value);
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.OptionalInt;
/** SETBIT result preserves the previous bit instead of mislabelling it as an affected count. */
record RedisBitmapMutationResult(
Status status, RedisPrimitiveMutationResult.Certainty certainty, OptionalInt previousBit) {
enum Status {
APPLIED,
WRONG_TYPE,
UNKNOWN
}
RedisBitmapMutationResult {
if (status == null || certainty == null || previousBit == null) {
throw new IllegalArgumentException("bitmap mutation result is invalid");
}
previousBit.ifPresent(
bit -> {
if (bit != 0 && bit != 1) {
throw new IllegalArgumentException("previous bitmap bit is invalid");
}
});
}
static RedisBitmapMutationResult from(RedisPrimitiveReply reply) {
return switch (reply.status()) {
case APPLIED ->
new RedisBitmapMutationResult(
Status.APPLIED,
RedisPrimitiveMutationResult.Certainty.APPLIED,
OptionalInt.of(Math.toIntExact(reply.signedNumber().orElseThrow())));
case WRONG_TYPE ->
new RedisBitmapMutationResult(
Status.WRONG_TYPE,
RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
default ->
new RedisBitmapMutationResult(
Status.UNKNOWN,
RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
};
}
static RedisBitmapMutationResult failed(RedisCommandFailureException failure) {
return new RedisBitmapMutationResult(
Status.UNKNOWN,
failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? RedisPrimitiveMutationResult.Certainty.INDETERMINATE
: RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Offset constrained to a descriptor-owned fixed bitmap domain. */
record RedisBitmapOffset(long value, long maximumExclusive) {
RedisBitmapOffset {
if (maximumExclusive < 1 || value < 0 || value >= maximumExclusive) {
throw new IllegalArgumentException("bitmap offset exceeds the fixed descriptor domain");
}
}
static RedisBitmapOffset of(long value, long maximumExclusive) {
return new RedisBitmapOffset(value, maximumExclusive);
}
long byteIndex() {
return value / Byte.SIZE;
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.List;
import java.util.Objects;
/** Fixed-domain non-authoritative bitmap helpers. */
final class RedisBitmapPrimitives {
private static final long MAXIMUM_OFFSET_EXCLUSIVE = 8_388_608;
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
RedisBitmapPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.BITMAP_GET).key(slot, identity);
}
RedisBitmapOffset offset(long value) {
return RedisBitmapOffset.of(value, MAXIMUM_OFFSET_EXCLUSIVE);
}
RedisBitmapByteOffset byteOffset(long value) {
return RedisBitmapByteOffset.of(value);
}
RedisPrimitiveReply get(RedisPrimitiveKey key, RedisBitmapOffset offset) {
return executor.execute(
RedisPrimitiveId.BITMAP_GET,
List.of(key),
new RedisPrimitiveInvocation.BitmapArguments(offset, offset, -1));
}
RedisBitmapMutationResult set(RedisPrimitiveKey key, RedisBitmapOffset offset, boolean bit) {
try {
return RedisBitmapMutationResult.from(
executor.execute(
RedisPrimitiveId.BITMAP_SET,
List.of(key),
new RedisPrimitiveInvocation.BitmapArguments(offset, offset, bit ? 1 : 0)));
} catch (RedisCommandFailureException failure) {
return RedisBitmapMutationResult.failed(failure);
}
}
RedisPrimitiveReply count(
RedisPrimitiveKey key, RedisBitmapByteOffset first, RedisBitmapByteOffset last) {
return executor.execute(
RedisPrimitiveId.BITMAP_COUNT_FIXED_RANGE,
List.of(key),
new RedisPrimitiveInvocation.BitmapCountArguments(first, last));
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.codec.RedisCodec;
import java.nio.ByteBuffer;
import java.util.Objects;
/**
* Rejects an oversized Redis bulk value before allocating its destination byte array.
*
* <p>RESP aggregate element count and aggregate reply bytes are additionally checked by the
* semantic router because a codec invocation sees only one bulk element. Lettuce constructs the
* aggregate list before that final check, so multi-value commands remain restricted to the vetted
* program catalog and its bounded reply schemas; this codec is the pre-allocation bound for each
* bulk element, not a claim of a pre-allocation aggregate-list bound.
*/
final class RedisBoundedByteArrayCodec implements RedisCodec<byte[], byte[]> {
private final int maximumBulkBytes;
RedisBoundedByteArrayCodec(int maximumBulkBytes) {
if (maximumBulkBytes < 1024 || maximumBulkBytes > 16_777_216) {
throw new IllegalArgumentException("Redis codec bulk byte bound must be in 1024..16777216");
}
this.maximumBulkBytes = maximumBulkBytes;
}
@Override
public byte[] decodeKey(ByteBuffer bytes) {
return decode(bytes);
}
@Override
public byte[] decodeValue(ByteBuffer bytes) {
return decode(bytes);
}
@Override
public ByteBuffer encodeKey(byte[] key) {
return encode(key);
}
@Override
public ByteBuffer encodeValue(byte[] value) {
return encode(value);
}
private byte[] decode(ByteBuffer bytes) {
Objects.requireNonNull(bytes, "Redis decode buffer must be non-null");
if (bytes.remaining() > maximumBulkBytes) {
throw new IllegalStateException("Redis response bulk value exceeds its configured bound");
}
byte[] value = new byte[bytes.remaining()];
bytes.get(value);
return value;
}
private ByteBuffer encode(byte[] value) {
Objects.requireNonNull(value, "Redis encode value must be non-null");
if (value.length > maximumBulkBytes) {
throw new IllegalArgumentException("Redis command bulk value exceeds its configured bound");
}
return ByteBuffer.wrap(value);
}
}
@@ -2,7 +2,14 @@ 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.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.application.cache.CacheRegionPort; import dev.caskeleton.application.cache.CacheObservationPort;
import dev.caskeleton.application.cache.DisabledCacheObservationPort;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Clock;
import java.util.Arrays;
import java.util.Set;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; 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.boot.context.properties.EnableConfigurationProperties;
@@ -23,7 +30,11 @@ import org.springframework.context.annotation.Configuration;
* 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(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisRuntimeSettings.class) @EnableConfigurationProperties({RedisRuntimeSettings.class, RedisLocalCacheSettings.class})
@ConditionalOnProperty(
name = "ca-skeleton.providers.redis.legacy-migration-enabled",
havingValue = "true",
matchIfMissing = false)
public class RedisCacheAdapterConfig { public class RedisCacheAdapterConfig {
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@@ -44,14 +55,17 @@ public class RedisCacheAdapterConfig {
} }
} }
@Bean @Bean(destroyMethod = "close")
@ConditionalOnBean(LettuceRedisRuntime.class) @ConditionalOnBean(LettuceRedisRuntime.class)
@ConditionalOnProperty( @ConditionalOnProperty(
name = "app.cache.redis.enabled", name = "app.cache.redis.enabled",
havingValue = "true", havingValue = "true",
matchIfMissing = false) matchIfMissing = false)
CacheRegionPort<String, String> redisStringCacheRegion( RedisCacheRegionRuntime redisStringCacheRegion(
LettuceRedisRuntime runtime, RedisRuntimeSettings settings) { LettuceRedisRuntime runtime,
RedisRuntimeSettings settings,
RedisLocalCacheSettings localSettings,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
RedisKeyNamespace namespace = RedisKeyNamespace namespace =
new RedisKeyNamespace( new RedisKeyNamespace(
settings.namespaceApplication(), settings.namespaceApplication(),
@@ -62,14 +76,75 @@ public class RedisCacheAdapterConfig {
1, 1,
"entry", "entry",
512); 512);
return new RedisStringCacheRegion( byte[] policySecret = settings.hmacSecret();
new RedisCacheRegionPolicy( RedisCacheRegionPolicy policy;
namespace, try {
settings.hmacSecret(), policy =
settings.positiveTtl(), new RedisCacheRegionPolicy(
settings.negativeTtl(), namespace,
settings.maximumValueBytes()), policySecret,
runtime); "runtime-settings-v2",
settings.positiveSoftTtl(),
settings.positiveTtl(),
settings.negativeTtl(),
settings.ttlJitter(),
settings.minimumHardTtl(),
settings.maximumValueBytes());
} finally {
Arrays.fill(policySecret, (byte) 0);
}
RedisStringCacheRegion l2 = new RedisStringCacheRegion(policy, runtime);
if (!localSettings.enabled()) {
return RedisCacheRegionRuntime.l2Only(l2);
}
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
CacheObservationPort observations =
meterRegistry == null
? DisabledCacheObservationPort.instance()
: new MicrometerCacheObservationPort(meterRegistry, Set.of(settings.semanticRegion()));
String channel = l2.invalidationChannel();
byte[] codecSecret = settings.hmacSecret();
RedisCacheInvalidationMessage.Codec codec;
try {
codec = RedisCacheInvalidationMessage.Codec.fromOwnedSecret(codecSecret);
} finally {
Arrays.fill(codecSecret, (byte) 0);
}
return RedisCacheRegionRuntime.local(
l2,
new RedisLocalCacheRegion(
settings.semanticRegion(),
l2,
localSettings.policy(),
Clock.systemUTC(),
observations,
channel,
codec,
message -> runtime.publishInvalidation(channel, message)));
}
@Bean(destroyMethod = "close")
@ConditionalOnBean(LettuceRedisRuntime.class)
@ConditionalOnProperty(
name = {"app.cache.redis.enabled", "app.cache.redis.l1.enabled"},
havingValue = "true",
matchIfMissing = false)
LettuceRedisCacheInvalidationSubscription redisCacheInvalidationSubscription(
LettuceRedisRuntime runtime,
@Qualifier("redisStringCacheRegion") RedisCacheRegionRuntime cacheRegion) {
RedisLocalCacheRegion local =
cacheRegion
.local()
.orElseThrow(
() ->
new IllegalStateException(
"Redis L1 invalidation subscription requires the cache-only local"
+ " decorator"));
return LettuceRedisCacheInvalidationSubscription.subscribe(
runtime,
local.invalidationChannel(),
local.invalidationMessageCodec(),
local.invalidationSubscriber());
} }
@Bean @Bean

Some files were not shown because too many files have changed in this diff Show More