VulnFlow 0.4.0 consists of a Spring Boot modular-monolith backend, a shared processing core, prepared AWS adapters/Lambda artifact, and an independent Java 17 scanning agent. PostgreSQL and a local report volume remain the backend persistence layer. The agent has its own filesystem outbox and only outbound HTTP connectivity.
AgentApplication
-> AgentConfigLoader -> configured TargetRegistry
-> AgentScheduler
-> ScanCoordinator -> TrivyImageScanner -> FileAgentOutbox
-> UploadCoordinator
-> AssetCache
-> VulnFlowHttpClient
-> FileAgentOutbox
-> uploaded-only cleanup
AssetController PUT /api/v1/assets/resolve
-> AssetService
-> AssetIdentityRepository -> INSERT ON CONFLICT
-> AssetRepository
ScanIngestionController
-> DefaultScanIngestionService
-> AssetService
-> ScanRegistrationService
-> ScanRepository
-> ReportStorage -> LocalFileReportStorage
-> IngestionJobRepository
LocalIngestionWorker (@Scheduled)
-> IngestionJobRecoveryService
-> JobClaimService -> PostgreSQL FOR UPDATE SKIP LOCKED
-> IngestionJobProcessor
-> ReportStorage.load
-> VulnerabilityReportProcessor
-> PayloadIntegrityVerifier
-> TrivyVulnerabilityReportParser
-> FindingRiskCalculator
-> IngestionPersistenceService
-> ProcessingResultStore<LocalCompletionContext>
-> FindingRepository
-> ScanRepository
-> IngestionJobRepository
-> JobFailureService
-> JobFailureClassifier
IngestionJobController
-> IngestionJobQueryService
-> IngestionJobRedriveService
- Startup validates every required setting and target, then executes a bounded
trivy --versionwithout a shell. - A scan cycle takes only configured targets, applies global and per-target concurrency guards, and invokes Trivy with an argument list.
- The bounded valid JSON is normalized only by removing the volatile root
CreatedAt, then copied to an atomically created UUID outbox directory. - Scanning succeeds even when the backend is unavailable. A cached asset ID is optional until the upload cycle can resolve it.
- Upload atomically claims a ready item, verifies SHA-256, and calls the idempotent asset resolver when needed.
- The client sends multipart JSON to the existing ingestion endpoint.
200and202becomeUPLOADED; network/5xxfailures set persisted backoff; functional4xxfailures dead-letter. - The unchanged backend queue and worker turn the report into findings.
The agent scheduler, scanner, uploader, target registry, outbox, and client are separate interfaces or focused classes. There is no inbound agent API.
- Spring Security validates
X-API-Key. - The controller validates multipart transport, asset, media type, and size.
- The service reads at most the configured upload limit and computes SHA-256.
- Registration inserts a
RECEIVEDscan withON CONFLICT DO NOTHINGand locks the matching(asset_id, content_hash)row. - For new content, an internal storage key is generated, the report is written
through a temporary file, and one
PENDINGjob is inserted. - Registration commits and HTTP returns
202; no JSON parsing occurs in the request thread. - The scheduler selects available jobs with
FOR UPDATE SKIP LOCKED LIMIT n. - A short claim transaction sets the job and scan to
PROCESSING, increments attempts, storeslockedAt, generates a non-reusable claim-token UUID, and returns a detachedJobClaim. - After commit, the worker loads the report, verifies its SHA-256, parses it, and calculates findings.
- A completion transaction locks job then scan, verifies the claim token,
replaces findings, and marks both records
COMPLETEDatomically. - Functional errors dead-letter immediately. Transient errors schedule a future retry or dead-letter after the maximum attempt.
Scan is the domain result:
RECEIVED: durable payload/job waiting, retry waiting, or redriven.PROCESSING: the current job attempt owns a lease.COMPLETED: findings and scanner version committed.FAILED: the job isDEAD_LETTER.
IngestionJob is the work source of truth:
PENDING,PROCESSING,RETRY_WAIT,COMPLETED,DEAD_LETTER.
There is no scan-only recovery path in 0.2.0. Recovery reads and transitions jobs, then updates their scans in the same transaction.
| Boundary | Operations | External work under DB lock |
|---|---|---|
| Registration | scan deduplication, payload write, job insert | Bounded local write, no parsing |
| Claim | job lock, attempt increment, scan PROCESSING |
None |
| Processing | storage read, JSON parse, risk calculation | No DB transaction |
| Completion | finding replacement, scan and job completion | None |
| Failure | retry/dead-letter and matching scan transition | None |
| Recovery | stale job retry/dead-letter and scan transition | None |
| Redrive | state check, payload existence, reset job and scan | Bounded existence check |
The registration transaction registers payload cleanup on rollback because a filesystem and PostgreSQL do not provide a distributed transaction.
UNIQUE(type, external_reference)plusINSERT ... ON CONFLICT DO NOTHINGresolves one asset under concurrent agents; an existing asset keeps its name.(asset_id, content_hash)serializes duplicate report submission.UNIQUE(scan_id)prevents a second job for a scan.SKIP LOCKEDlets backend instances claim different jobs without waiting.- Claim transactions finish before parsing.
attemptCountcounts retry budget and selects backoff; it is not fencing.claimTokenis a new UUID for every claim. Completion and failure require the current token, which prevents ABA after recovery and manual redrive.- Completion deletes and recreates a scan's findings within one transaction, so redelivery cannot leave duplicates or partial findings.
- Concurrent redrive locks the job row; only the first transition succeeds.
- One scan cycle and a per-target stable-key set prevent target overlap. A fixed executor caps global Trivy concurrency.
- Outbox claims are synchronized and move persisted state to
UPLOADINGbefore HTTP. A second upload cycle cannot claim that item.
Stale PROCESSING jobs are selected by lockedAt. If attempts remain they move
to RETRY_WAIT, otherwise to DEAD_LETTER. Recovery uses row locks and is
idempotent because the first execution changes the status. Both paths clear the
claim token before releasing the transaction.
The default retry sequence is 5 seconds, 30 seconds, and 2 minutes. Retries set
availableAt; no worker sleeps while waiting.
pull request
-> backend mvn verify -> mandatory PostgreSQL integration reports
-> agent mvn verify
-> local backend and agent image builds
push to main
-> the same verification boundary
-> GHCR backend:<commit-sha> + agent:<commit-sha>
-> protected production environment and enable switch
-> pinned-host SSH + deployment-file synchronization
-> Docker Compose pull/up -> Flyway startup -> health gate
-> accept release or restore previous image manifest
CI owns compilation, tests, and image publication. The VPS is a runtime host;
it receives only the deploy/ bundle and immutable image references. Its
runtime/.env.prod and runtime/targets.yml remain outside the synchronized
directory. GitHub Actions does not transmit the VulnFlow API key or PostgreSQL
password.
The production Compose topology is:
host Nginx/TLS
-> 127.0.0.1:8080 -> backend
-> PostgreSQL (private network, named volume)
agent (private network) -> backend
-> Trivy -> named outbox/cache volume
-> read-only targets file
PostgreSQL has no host port. The backend report store and agent data are named volumes. The agent does not receive the Docker socket, so this topology supports registry-accessible image targets only. Host-local Docker images require the separately documented systemd model and are not silently enabled by CI/CD.
Deployment serialization exists at two levels: GitHub uses the
vulnflow-production concurrency group with cancellation disabled, and the VPS
uses a non-blocking flock. The candidate release manifest must contain the two
GHCR image references and one matching 40-character commit SHA. The previous
manifest is retained before Compose is updated.
Rollback restores application image references and repeats the health check; it
does not reverse PostgreSQL. Flyway runs during normal backend startup, so
production migrations must remain compatible with the preceding release.
Explicit volume names let an operator adopt existing VPS volumes without
recreation. No deployment command invokes down, volume deletion, or pruning.
LocalFileReportStorage -> S3ReportStorage
IngestionJobRepository/claim service -> SQS delivery adapter
LocalIngestionWorker -> Lambda handler
The agent's future transport seam is:
FileAgentOutbox -> presigned S3 uploader -> SQS submission
The S3/SQS implementations, PostgreSQL publication outbox, Lambda handler,
DynamoDB result store, and AWS query adapter are executable under the explicit
aws profile. The local profile creates no AWS SDK clients. No cloud resource
is created or contacted by 0.4.1; see docs/aws-architecture.md for transaction
boundaries and failure windows. SQS uses receipt handles and visibility timeouts
rather than copying the local job protocol, while the backend outbox uses only
short PostgreSQL claims and never holds a row lock during S3/SQS calls.