Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/acceptance_test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: acceptance-test

on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize

permissions:
contents: read

jobs:
acceptance-test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "./go.mod"

- name: Run acceptance tests
run: make test-acceptance COMPOSE="docker compose"
6 changes: 6 additions & 0 deletions .jscpd.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"ignore": [
"vendor/**",
"**/vendor/**"
]
}
3 changes: 3 additions & 0 deletions .mega-linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ ENABLE_LINTERS:
- REPOSITORY_KICS
- YAML_YAMLLINT
REPOSITORY_KICS_ARGUMENTS: "--fail-on high"
# Allow zizmor to use GITHUB_TOKEN for GitHub API access
ACTION_ZIZMOR_UNSECURED_ENV_VARIABLES:
- GITHUB_TOKEN
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ make test-behavioral
# Devcontainer smoke test (verifies Containerfile builds)
make test-devcontainer
# → podman build -t complyctl-devcontainer-test .devcontainer/

# Acceptance tests (container-based, requires podman-compose or docker compose)
make test-acceptance
# → compose up --profile lifecycle (zot + seed + SUT containers)
```

### Lint & Format
Expand Down Expand Up @@ -86,6 +90,7 @@ make crapload-check # check for CRAP regressions against baseline
| SonarCloud | `ci_sonarcloud.yml` | Code quality analysis |
| Behavioral | `behavioral_assessment.yml` | Behavioral assessment reports |
| Scheduled | `ci_scheduled.yml` | Daily OSV-Scanner and Scorecards |
| Acceptance Test | `acceptance_test.yml` | Container-based acceptance tests with real OCI registry |
| Release | `release.yml` | Release automation |

## Project Structure
Expand Down Expand Up @@ -125,6 +130,7 @@ plans/ # feature planning artifacts
scripts/ # maintenance scripts (SPDX checks, workflow setup)
specs/ # Speckit strategic specifications (NNN-*/ format)
tests/
├── acceptance/ # Container-based acceptance tests (compose + zot)
├── behavioral/ # behavioral test scenarios
├── cross-repo/ # cross-repo integration tests (complyctl + ampel + opa providers)
├── e2e/ # E2E tests (build-tag gated: -tags=e2e)
Expand All @@ -151,6 +157,18 @@ vendor/ # vendored dependencies
- **Line length**: 99 characters max unless exceeding improves
readability

### Container Image Conventions

- **Base images**: Red Hat UBI (`ubi10/ubi-minimal`) MUST
NOT be pinned by digest hash. Use `:latest` to track
current errata and security patches. Dismiss Scorecard
`Pinned-Dependencies` alerts for UBI images — this is
an intentional tradeoff favoring automatic errata
coverage over reproducibility.
- **Trivy suppressions**: Use `# trivy:ignore:DSXXX`
comments inline above the `FROM` line for accepted
findings on test/ephemeral containers.

### Spec Writing Conventions

- Use RFC 2119 language (MUST/SHOULD/MAY) for requirements
Expand Down Expand Up @@ -286,6 +304,7 @@ packages organized by domain responsibility.
## Recent Changes
- complypack-cache-versioning: `COMPLYTIME_CACHE_VERSIONS` env var configures retention count (default 1) for complypack cache versions per evaluator-id; `NewComplypackCache()` gains `*State` parameter for state-driven lookup and timestamp-based eviction ordering; `evictOldVersions()` becomes retention-count-aware (orphaned dirs first, then oldest by `LastUpdated`); `LookupByEvaluatorID()` resolves from state.json with directory-scan fallback; `SyncComplypack()` checks local cache before remote fetch with re-verification when verifier is configured, returns `(true, nil)` for cache hits to trigger generation invalidation; `EvaluatorIDToVersion()` reverse lookup on `*State`; `CacheRetentionCount()` in `internal/cache/retention.go`; `CheckComplypacks()` extended with `walkCacheSize()` and `findOrphanedVersions()` for cache health reporting; `complyctl doctor` reports cache size and orphaned/untracked versions (#676)
- per-entry-verification: Per-entry `verification:` and `skip_verify:` fields on `PolicyEntry` in `internal/complytime/config.go` for policy-level signature verification overrides; `resolveVerifier()` in `cmd/complyctl/cli/get.go` with verifier cache and resolution priority chain (entry → workspace → none); error collection in `syncAllPolicies()`/`syncAllComplypacks()` via `errors.Join()` for partial-failure resilience; cross-group error collection in `syncAll()` so policy failures do not block complypack sync; per-entry WARNING to stderr on sync failure for real-time feedback; `validateEntries()` extended for per-entry verification validation and mutual exclusivity of `verification:` and `skip_verify:` (#680)
- acceptance-tests: Container-based acceptance test stack (`tests/acceptance/`, `make test-acceptance`, `acceptance_test.yml`); zot + seed + sut compose architecture validates real OCI registry interop; `build-acceptance-test` target compiles acceptance binary; `test-acceptance-clean` tears down containers and volumes; `SaveState` atomic write refactor in `internal/cache/state.go`
- debug-visible-output: `--debug` flag now tees all log messages to stderr via `teeWriter` in `pkg/log/log.go`; `enableDebug()` in `cmd/complyctl/cli/root.go` reconstructs logger with `NewTeeWriter(stderr, logFile)` and forces `termenv.ANSI256` color profile when stderr is a TTY (respects `NO_COLOR`); `Debug log: <path>` hint printed to stderr after workspace resolution; flag description updated to `"output debug logs to stderr and log file"`; `SetColorProfile()` method added to `CharmHclog` adapter (#614)
- sigstore-verification: `complyctl get` verifies OCI artifact signatures via `sigstore-go` when `verification:` configured in `complytime.yaml`; keyless (OIDC issuer + identity) and keyed (public key) modes; pre-copy verification via registry API before `oras.Copy()`; `--skip-verify` flag; `VerificationConfig` in `WorkspaceConfig`; `VerifyFunc`/`NewKeylessVerifier`/`NewKeyedVerifier` in `internal/cache/verify.go`; `PolicyState` gains `Verified`/`SignerIdentity`/`Issuer`/`VerifiedAt` fields; `SyncOption`/`WithVerifier()` functional options on `Sync`/`ComplypackSync`; `complyctl list` VERIFIED column; `complyctl doctor` `CheckVerification` diagnostic; `sigstore-go` v1.2.1 + `go-containerregistry` dependencies added
- markdown-report-redesign: `--format pretty` markdown report redesigned with summary metadata table, pass rate, grouped controls table (control + requirement rows with message column), findings section grouped by result type with recommendation and collapsible evidence per finding, evaluation log in collapsible `<details>`; `Evidence` message and `recommendation` field added to proto `AssessmentLog`; `provider.Evidence` type added to `pkg/provider/client.go`; evaluator populates `gemara.Evidence` and `Recommendation` on assessment logs; `internal/output/markdown.go` rewritten with `writeSummary`/`writeControlsTable`/`writeFindings`/`writeEvaluationLog` methods
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
error. Verifiers are cached by configuration to avoid redundant
construction for entries sharing the same verification settings.
(#680)
- Container-based acceptance tests (`make test-acceptance`): runs
complyctl against a real zot OCI registry in a compose stack,
validating OCI artifact interop that in-process mock registries
cannot catch (#653)
- Redesigned markdown report (`--format pretty`) with summary metadata
table, pass rate, grouped controls table with messages, findings
section grouped by result type with recommendation and collapsible
Expand Down
39 changes: 39 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ GO_LD_EXTRAFLAGS := -X github.com/complytime/complyctl/internal/version.version=
MAN_COMPLYCTL = docs/man/complyctl.md
MAN_COMPLYCTL_OUTPUT = docs/man/complyctl.1

COMPOSE ?= $(shell command -v podman-compose 2>/dev/null || echo "docker compose")

# Derive CONTAINER_ENGINE from COMPOSE to avoid cross-engine mismatches
# (e.g. docker compose creates containers, but podman ps cannot see them).
ifeq ($(findstring docker,$(COMPOSE)),docker)
CONTAINER_ENGINE ?= docker
else
CONTAINER_ENGINE ?= $(shell command -v podman 2>/dev/null || echo docker)
endif

.DEFAULT_GOAL := help

##@ Proto

proto: ## generate protobuf code (requires buf)
Expand Down Expand Up @@ -62,6 +74,29 @@ test-devcontainer: ## verify devcontainer Containerfile builds
@echo "Containerfile builds successfully."
.PHONY: test-devcontainer

ACCEPTANCE_COMPOSE = $(COMPOSE) -f tests/acceptance/compose.yaml --profile lifecycle

test-acceptance: build build-test-provider build-acceptance-test ## run container-based acceptance tests (requires podman-compose or docker compose)
@$(ACCEPTANCE_COMPOSE) up --build -d || { $(ACCEPTANCE_COMPOSE) down -v ; exit 1 ; } ; \
sut=$$($(CONTAINER_ENGINE) ps -aq --filter name=sut | head -1) ; \
if [ -z "$$sut" ]; then \
echo "ERROR: SUT container not found" ; \
$(ACCEPTANCE_COMPOSE) down -v ; \
exit 1 ; \
Comment thread
trevor-vaughan marked this conversation as resolved.
fi ; \
rc=$$($(CONTAINER_ENGINE) wait $$sut) ; \
echo "=== seed logs ===" ; \
$(ACCEPTANCE_COMPOSE) logs seed 2>&1 || true ; \
echo "=== sut logs ===" ; \
$(CONTAINER_ENGINE) logs $$sut ; \
$(ACCEPTANCE_COMPOSE) down -v ; \
exit $$rc
.PHONY: test-acceptance

test-acceptance-clean: ## tear down acceptance test containers and volumes
$(ACCEPTANCE_COMPOSE) down -v --remove-orphans
.PHONY: test-acceptance-clean

##@ Compilation

all: clean vendor test-unit build ## compile from scratch
Expand All @@ -75,6 +110,10 @@ build-test-provider: prep-build-dir ## build test provider for E2E tests
go build -mod=vendor -o $(GO_BUILD_BINDIR)/complyctl-provider-test ./cmd/test-provider
.PHONY: build-test-provider

build-acceptance-test: prep-build-dir ## compile acceptance test binary
go test -c -tags=acceptance -mod=vendor -o $(GO_BUILD_BINDIR)/acceptance.test ./tests/acceptance/
.PHONY: build-acceptance-test

build-behavioral-report: prep-build-dir ## build behavioral report tool (go test -json -> EvaluationLog + SARIF)
go build -mod=vendor -o $(GO_BUILD_BINDIR)/behavioral-report ./cmd/behavioral-report
.PHONY: build-behavioral-report
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,6 @@ complyctl scan --policy-id nist-800-53-r5
complyctl scan prod --format oscal
complyctl scan --policy-id nist-800-53-r5 --format pretty
complyctl scan --policy-id nist-800-53-r5 --format sarif

```

| Argument / Flag | Short | Description |
Expand Down
43 changes: 43 additions & 0 deletions docs/TESTING_ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,49 @@ After resuming, restart the mock registry manually:
./bin/mock-oci-registry &
```

## Container-Based Acceptance Tests

The acceptance test suite validates real OCI registry interop using
a three-container compose stack. Unlike the devcontainer environment,
acceptance tests are automated and run in CI on every PR.

### Prerequisites

You need `podman-compose` or `docker compose` (v2) and a container
runtime (`podman` or `docker`).

### Running

```bash
# Build binaries and run the full acceptance suite
make test-acceptance

# Specify a different compose command (default: podman-compose)
make test-acceptance COMPOSE="docker compose"

# Tear down containers and volumes after a failed run
make test-acceptance-clean
```

### Architecture

The compose stack (`tests/acceptance/compose.yaml`) runs three
services under the `lifecycle` profile:

- **zot** -- a real OCI-compliant registry (Project Zot) listening
on port 5000 inside the compose network.
- **seed** -- a short-lived container that uses the `oras` CLI to
push Gemara test policies into zot, then exits. The seed service
must complete successfully before the SUT starts.
- **sut** (system under test) -- runs the acceptance test binary
(`go test -tags=acceptance ./tests/acceptance/...`) against the
live registry. The compose stack exits with the SUT's exit code.

### CI

The `acceptance_test.yml` workflow runs `make test-acceptance` on
every push and PR using `docker compose`.

## See Also

- [Quick Start](./QUICK_START.md)
Expand Down
31 changes: 29 additions & 2 deletions internal/cache/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ func initStateMaps(s *State) {
}
}

// SaveState writes the state to state.json in the given cache directory.
// SaveState atomically writes the state to state.json in the given cache
// directory. It marshals to a sibling temp file then renames it into place so
// concurrent readers never observe a truncated or partial write.
func SaveState(state *State, cacheDir string) error {
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
Expand All @@ -85,7 +87,32 @@ func SaveState(state *State, cacheDir string) error {
return fmt.Errorf("failed to marshal state: %w", err)
}

if err := os.WriteFile(statePath, data, 0600); err != nil {
// Write to a temp file in the same directory so os.Rename is atomic
// (same filesystem, POSIX guarantee). This prevents concurrent readers
// from observing a truncated file mid-write.
tmp, err := os.CreateTemp(cacheDir, ".state-*.json.tmp")
if err != nil {
return fmt.Errorf("failed to create temp state file: %w", err)
}
tmpPath := tmp.Name()

if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to write temp state file %s: %w", tmpPath, err)
}
if err := tmp.Chmod(0600); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to set permissions on temp state file: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to close temp state file: %w", err)
}

if err := os.Rename(tmpPath, statePath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to write state file %s: %w", statePath, err)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/policy/generation_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func InvalidateForEvaluator(baseDir, evaluatorID string) (warnings []string, _ e
if os.IsNotExist(rootErr) {
return nil, nil
}
return nil, rootErr
return nil, fmt.Errorf("failed to open generation directory: %w", rootErr)
}
defer root.Close()

Expand Down
23 changes: 23 additions & 0 deletions tests/acceptance/Dockerfile.seed
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SPDX-License-Identifier: Apache-2.0
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
trevor-vaughan marked this conversation as resolved.
Dismissed
# trivy:ignore:DS001 -- test container, intentionally floats on latest UBI
# trivy:ignore:DS026 -- ephemeral seed container, exits after seeding registry
# scorecard:pinned-dependencies -- intentionally unpinned; UBI images track latest for errata coverage
FROM registry.access.redhat.com/ubi10/ubi-minimal:latest
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
trevor-vaughan marked this conversation as resolved.
Dismissed
Comment thread
trevor-vaughan marked this conversation as resolved.
Dismissed

ARG ORAS_VERSION=1.2.2
# sha256 from https://github.com/oras-project/oras/releases/tag/v1.2.2 checksums.txt
ARG ORAS_SHA256=bff970346470e5ef888e9f2c0bf7f8ee47283f5a45207d6e7a037da1fb0eae0d

RUN microdnf install -y tar gzip && microdnf clean all

ADD https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_amd64.tar.gz /tmp/oras.tar.gz
RUN echo "${ORAS_SHA256} /tmp/oras.tar.gz" | sha256sum -c - && \
tar -xzf /tmp/oras.tar.gz -C /usr/local/bin oras && \
rm /tmp/oras.tar.gz && \
chmod +x /usr/local/bin/oras

COPY seed.sh /seed.sh
RUN chmod +x /seed.sh

USER nobody
ENTRYPOINT ["/seed.sh"]
25 changes: 25 additions & 0 deletions tests/acceptance/Dockerfile.sut
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# SPDX-License-Identifier: Apache-2.0
Comment thread
trevor-vaughan marked this conversation as resolved.
Dismissed

# Runtime-only container for acceptance tests.
# All binaries are built locally and injected from bin/.
# trivy:ignore:DS001 -- test container, intentionally floats on latest UBI
# trivy:ignore:DS026 -- ephemeral test container, compose healthcheck covers readiness
# scorecard:pinned-dependencies -- intentionally unpinned; UBI images track latest for errata coverage
FROM registry.access.redhat.com/ubi10/ubi-minimal:latest
Comment thread
trevor-vaughan marked this conversation as resolved.
Dismissed
Comment thread
trevor-vaughan marked this conversation as resolved.
Dismissed

RUN microdnf install -y shadow-utils && microdnf clean all && \
useradd -m testuser

COPY bin/acceptance.test /usr/local/bin/acceptance.test
COPY bin/complyctl /usr/local/bin/complyctl
COPY bin/complyctl-provider-test \
/home/testuser/.complytime/providers/complyctl-provider-test

RUN chown -R testuser:testuser /home/testuser/.complytime && \
chmod 755 /home/testuser/.complytime/providers/complyctl-provider-test

USER testuser
WORKDIR /home/testuser

ENTRYPOINT ["/usr/local/bin/acceptance.test"]
CMD ["-test.v", "-test.timeout=3m"]
6 changes: 6 additions & 0 deletions tests/acceptance/Dockerfile.sut.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# SUT container only needs pre-built binaries from bin/.
# Exclude everything else.
*
!bin/acceptance.test
!bin/complyctl
!bin/complyctl-provider-test
Loading
Loading