Skip to content
Draft
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
28 changes: 5 additions & 23 deletions cmd/complyctl/cli/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,15 @@ func (o *getOptions) syncPolicies(ctx context.Context, cfg *complytime.Workspace
return fmt.Errorf("authentication setup failed: %w", err)
}

return syncAllPolicies(ctx, cacheMgr, state, credFunc, cfg.Policies, o.cacheDir)
return syncAllPolicies(ctx, cacheMgr, state, credFunc, cfg.Policies)
}

func syncAllPolicies(ctx context.Context, cacheMgr *cache.Cache, state *cache.State, credFunc auth.CredentialFunc, policies []complytime.PolicyEntry, cacheDir string) error {
func syncAllPolicies(ctx context.Context, cacheMgr *cache.Cache, state *cache.State, credFunc auth.CredentialFunc, policies []complytime.PolicyEntry) error {
logger.Info("Starting policy synchronization", "policy_count", len(policies))

total := len(policies)
for i, entry := range policies {
if err := syncSinglePolicy(ctx, cacheMgr, state, credFunc, entry, i+1, total, cacheDir); err != nil {
if err := syncSinglePolicy(ctx, cacheMgr, state, credFunc, entry, i+1, total); err != nil {
return err
}
}
Expand All @@ -105,7 +105,7 @@ func syncAllPolicies(ctx context.Context, cacheMgr *cache.Cache, state *cache.St
return nil
}

func syncSinglePolicy(ctx context.Context, cacheMgr *cache.Cache, state *cache.State, credFunc auth.CredentialFunc, entry complytime.PolicyEntry, index, total int, cacheDir string) error {
func syncSinglePolicy(ctx context.Context, cacheMgr *cache.Cache, state *cache.State, credFunc auth.CredentialFunc, entry complytime.PolicyEntry, index, total int) error {
ref := complytime.ParsePolicyRef(entry.URL)
version := ref.Version

Expand All @@ -121,9 +121,8 @@ func syncSinglePolicy(ctx context.Context, cacheMgr *cache.Cache, state *cache.S
logger.Info("Syncing policy", "policy", ref.Repository, "version", version)
if err := sync.SyncPolicy(ctx, ref.Repository, version); err != nil {
fmt.Fprintln(os.Stderr, "failed")
suggestMsg := suggestCachedPolicyIDs(cacheDir, ref.Repository)
logger.Error("Policy sync failed", "policy", ref.Repository, "error", err)
return fmt.Errorf("failed to sync policy %s: %w%s", ref.Repository, err, suggestMsg)
return err
}
fmt.Fprintln(os.Stderr, "done")
logger.Info("Policy synced", "policy", entry.EffectiveID())
Expand All @@ -141,20 +140,3 @@ func resolveLatestVersion(ctx context.Context, client *registry.Client, reposito
logger.Info("Resolved version", "policy", policyID, "version", resolvedVersion)
return resolvedVersion
}

func suggestCachedPolicyIDs(cacheDir, failedPolicyID string) string {
state, err := cache.LoadState(cacheDir)
if err != nil || len(state.Policies) == 0 {
return ""
}
cached := make([]string, 0, len(state.Policies))
for id := range state.Policies {
if id != failedPolicyID {
cached = append(cached, id)
}
}
if len(cached) == 0 {
return ""
}
return fmt.Sprintf(" (cached policies: %v)", cached)
}
16 changes: 8 additions & 8 deletions internal/cache/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ package cache

import (
"context"
"errors"
"fmt"

"github.com/complytime/complyctl/internal/registry"
)

// Sync provides incremental sync using oras.Copy() for remote-to-local transfer.
Expand Down Expand Up @@ -37,10 +40,10 @@ func (s *Sync) SyncPolicy(ctx context.Context, policyID, version string) error {

remoteDigest, remoteVersion, err := s.source.DefinitionVersion(ctx, lookupRef)
if err != nil {
return fmt.Errorf(
"policy %s: registry unreachable: %w (cached data may still be available)",
policyID, err,
)
if errors.Is(err, registry.ErrVersionNotFound) {
return fmt.Errorf("policy %s: %w", policyID, err)
}
return fmt.Errorf("policy %s: registry unreachable: %w", policyID, err)
}

if version == "" || version == "latest" {
Expand All @@ -59,10 +62,7 @@ func (s *Sync) SyncPolicy(ctx context.Context, policyID, version string) error {

_, err = s.source.CopyPolicy(ctx, policyID, version, localStore)
if err != nil {
return fmt.Errorf(
"policy %s@%s: registry unreachable: %w (local cache unchanged)",
policyID, version, err,
)
return fmt.Errorf("policy %s@%s: copy failed: %w", policyID, version, err)
}

s.state.UpdatePolicyState(policyID, version, remoteDigest)
Expand Down
44 changes: 42 additions & 2 deletions internal/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package doctor

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
Expand All @@ -14,6 +15,7 @@ import (
"github.com/complytime/complyctl/internal/cache"
"github.com/complytime/complyctl/internal/complytime"
"github.com/complytime/complyctl/internal/policy"
"github.com/complytime/complyctl/internal/registry"
"github.com/complytime/complyctl/pkg/provider"
)

Expand Down Expand Up @@ -267,7 +269,28 @@ func CheckPolicyVersions(cfg *complytime.WorkspaceConfig, cacheDir string, versi
}

cachedVersion := cachedState.Version
if cachedVersion == latestVersion {

if ref.Version != "" {
if cachedVersion == ref.Version {
msg := fmt.Sprintf("%s (pinned)", cachedVersion)
if latestVersion != cachedVersion {
msg = fmt.Sprintf("%s (pinned — latest available: %s)", cachedVersion, latestVersion)
}
results = append(results, CheckResult{
Name: fmt.Sprintf("policy/%s", eid),
Status: StatusPass,
Message: msg,
Blocking: false,
})
} else {
results = append(results, CheckResult{
Name: fmt.Sprintf("policy/%s", eid),
Status: StatusWarn,
Message: fmt.Sprintf("cached %s does not match configured pin @%s — run complyctl get", cachedVersion, ref.Version),
Blocking: false,
})
}
} else if cachedVersion == latestVersion {
results = append(results, CheckResult{
Name: fmt.Sprintf("policy/%s", eid),
Status: StatusPass,
Expand All @@ -289,7 +312,7 @@ func CheckPolicyVersions(cfg *complytime.WorkspaceConfig, cacheDir string, versi

// resolvePinnedFallback attempts to resolve a pinned version when the latest
// tag is unavailable. Returns a pass result if the pinned version resolves,
// or a warn result marking the registry as unreachable.
// or a warn result with a user-friendly diagnosis.
func resolvePinnedFallback(
resolver VersionResolver,
ref complytime.PolicyRef,
Expand All @@ -307,6 +330,20 @@ func resolvePinnedFallback(
}
}
}

if errors.Is(latestErr, registry.ErrVersionNotFound) {
msg := "latest tag not found — pin a specific version with @<tag>"
if ref.Version != "" {
msg = fmt.Sprintf("version %q not found in registry", ref.Version)
}
return CheckResult{
Name: fmt.Sprintf("policy/%s", eid),
Status: StatusWarn,
Message: msg,
Blocking: false,
}
}

return CheckResult{
Name: fmt.Sprintf("registry/%s", ref.Registry),
Status: StatusWarn,
Expand Down Expand Up @@ -675,6 +712,9 @@ func unmappedReason(resolver PolicyGraphResolver, resolveFailures int) string {
// Non-blocking — the collector is optional. When configured, checks that the
// endpoint format looks valid and auth fields are complete.
func CheckCollector(cfg *complytime.WorkspaceConfig) []CheckResult {
if cfg == nil {
return nil
}
if cfg.Collector == nil {
return []CheckResult{{
Name: "collector",
Expand Down
136 changes: 123 additions & 13 deletions internal/doctor/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/complytime/complyctl/internal/cache"
"github.com/complytime/complyctl/internal/complytime"
"github.com/complytime/complyctl/internal/policy"
"github.com/complytime/complyctl/internal/registry"
)

// --- Mock VersionResolver ---
Expand All @@ -34,32 +35,32 @@ func newMockVersionResolver() *mockVersionResolver {
}
}

func (m *mockVersionResolver) ResolveLatestVersion(registry, repository string) (string, error) {
if m.unreachable[registry] {
func (m *mockVersionResolver) ResolveLatestVersion(reg, repository string) (string, error) {
if m.unreachable[reg] {
return "", fmt.Errorf("connection refused")
}
if m.latestMissing[registry] {
return "", fmt.Errorf("OCI version resolution failed for %s/%s:latest: not found", registry, repository)
if m.latestMissing[reg] {
return "", fmt.Errorf("%w: %s/%s tag %q", registry.ErrVersionNotFound, reg, repository, "latest")
}
key := registry + "|" + repository
key := reg + "|" + repository
if err, ok := m.errOnResolve[key]; ok {
return "", err
}
if v, ok := m.versions[key]; ok {
return v, nil
}
return "", fmt.Errorf("not found: %s/%s", registry, repository)
return "", fmt.Errorf("not found: %s/%s", reg, repository)
}

func (m *mockVersionResolver) ResolveVersion(registry, repository, version string) (string, error) {
if m.unreachable[registry] {
func (m *mockVersionResolver) ResolveVersion(reg, repository, version string) (string, error) {
if m.unreachable[reg] {
return "", fmt.Errorf("connection refused")
}
key := registry + "|" + repository + "|" + version
key := reg + "|" + repository + "|" + version
if v, ok := m.pinnedVersions[key]; ok {
return v, nil
}
return "", fmt.Errorf("not found: %s/%s:%s", registry, repository, version)
return "", fmt.Errorf("not found: %s/%s:%s", reg, repository, version)
}

// --- Mock PolicyGraphResolver ---
Expand Down Expand Up @@ -138,6 +139,37 @@ func TestCheckPolicyVersions_PolicyAtLatest(t *testing.T) {
vr := newMockVersionResolver()
vr.versions["reg.io|policies/nist"] = "v1.0.0"

results := CheckPolicyVersions(cfg, tmpDir, vr)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Status != StatusPass {
t.Errorf("expected pass, got %s: %s", results[0].Status, results[0].Message)
}
if !strings.Contains(results[0].Message, "(pinned)") {
t.Errorf("expected '(pinned)' in message, got %q", results[0].Message)
}
}

func TestCheckPolicyVersions_UnpinnedAtLatest(t *testing.T) {
tmpDir := t.TempDir()

state := &cache.State{Policies: map[string]cache.PolicyState{
"policies/nist": {Version: "v1.0.0"},
}}
if err := cache.SaveState(state, tmpDir); err != nil {
t.Fatal(err)
}

cfg := &complytime.WorkspaceConfig{
Policies: []complytime.PolicyEntry{
{URL: "reg.io/policies/nist"},
},
}

vr := newMockVersionResolver()
vr.versions["reg.io|policies/nist"] = "v1.0.0"

results := CheckPolicyVersions(cfg, tmpDir, vr)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
Expand All @@ -150,7 +182,7 @@ func TestCheckPolicyVersions_PolicyAtLatest(t *testing.T) {
}
}

func TestCheckPolicyVersions_PolicyStale(t *testing.T) {
func TestCheckPolicyVersions_PinnedMatchesCached_LatestDiffers(t *testing.T) {
tmpDir := t.TempDir()

state := &cache.State{Policies: map[string]cache.PolicyState{
Expand All @@ -169,6 +201,40 @@ func TestCheckPolicyVersions_PolicyStale(t *testing.T) {
vr := newMockVersionResolver()
vr.versions["reg.io|policies/nist"] = "v1.1.0"

results := CheckPolicyVersions(cfg, tmpDir, vr)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Status != StatusPass {
t.Errorf("expected pass for pinned matching cached, got %s: %s", results[0].Status, results[0].Message)
}
if !strings.Contains(results[0].Message, "(pinned") {
t.Errorf("expected 'pinned' in message, got %q", results[0].Message)
}
if !strings.Contains(results[0].Message, "latest available: v1.1.0") {
t.Errorf("expected 'latest available' info in message, got %q", results[0].Message)
}
}

func TestCheckPolicyVersions_UnpinnedStale(t *testing.T) {
tmpDir := t.TempDir()

state := &cache.State{Policies: map[string]cache.PolicyState{
"policies/nist": {Version: "v1.0.0"},
}}
if err := cache.SaveState(state, tmpDir); err != nil {
t.Fatal(err)
}

cfg := &complytime.WorkspaceConfig{
Policies: []complytime.PolicyEntry{
{URL: "reg.io/policies/nist"},
},
}

vr := newMockVersionResolver()
vr.versions["reg.io|policies/nist"] = "v1.1.0"

results := CheckPolicyVersions(cfg, tmpDir, vr)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
Expand All @@ -184,6 +250,40 @@ func TestCheckPolicyVersions_PolicyStale(t *testing.T) {
}
}

func TestCheckPolicyVersions_PinnedMismatchCached(t *testing.T) {
tmpDir := t.TempDir()

state := &cache.State{Policies: map[string]cache.PolicyState{
"policies/nist": {Version: "v1.0.0"},
}}
if err := cache.SaveState(state, tmpDir); err != nil {
t.Fatal(err)
}

cfg := &complytime.WorkspaceConfig{
Policies: []complytime.PolicyEntry{
{URL: "reg.io/policies/nist@v2.0.0"},
},
}

vr := newMockVersionResolver()
vr.versions["reg.io|policies/nist"] = "v2.0.0"

results := CheckPolicyVersions(cfg, tmpDir, vr)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Status != StatusWarn {
t.Errorf("expected warn for pin mismatch, got %s: %s", results[0].Status, results[0].Message)
}
if !strings.Contains(results[0].Message, "does not match configured pin") {
t.Errorf("expected pin mismatch message, got %q", results[0].Message)
}
if !strings.Contains(results[0].Message, "@v2.0.0") {
t.Errorf("expected configured version in message, got %q", results[0].Message)
}
}

func TestCheckPolicyVersions_NotCached(t *testing.T) {
tmpDir := t.TempDir()

Expand Down Expand Up @@ -316,8 +416,11 @@ func TestCheckPolicyVersions_LatestMissing_NoPinnedVersion(t *testing.T) {
if results[0].Status != StatusWarn {
t.Errorf("expected warn, got %s: %s", results[0].Status, results[0].Message)
}
if results[0].Name != "registry/reg.io" {
t.Errorf("expected registry warning, got %q", results[0].Name)
if !strings.Contains(results[0].Message, "latest tag not found") {
t.Errorf("expected 'latest tag not found' in message, got %q", results[0].Message)
}
if !strings.Contains(results[0].Message, "pin a specific version") {
t.Errorf("expected guidance to pin version, got %q", results[0].Message)
}
}

Expand Down Expand Up @@ -891,6 +994,13 @@ func TestCheckConfig_MissingFile(t *testing.T) {

// --- CheckCollector Tests ---

func TestCheckCollector_NilConfig(t *testing.T) {
results := CheckCollector(nil)
if results != nil {
t.Errorf("expected nil for nil config, got %d results", len(results))
}
}

func TestCheckCollector_NilCollector(t *testing.T) {
cfg := &complytime.WorkspaceConfig{}
results := CheckCollector(cfg)
Expand Down
Loading
Loading