diff --git a/cmd/complyctl/cli/doctor.go b/cmd/complyctl/cli/doctor.go index 4a9b6691..18727bd1 100644 --- a/cmd/complyctl/cli/doctor.go +++ b/cmd/complyctl/cli/doctor.go @@ -39,6 +39,14 @@ type registryVersionResolver struct { } func (r *registryVersionResolver) ResolveLatestVersion(registryURL, repository string) (string, error) { + return r.resolve(registryURL, repository, "") +} + +func (r *registryVersionResolver) ResolveVersion(registryURL, repository, version string) (string, error) { + return r.resolve(registryURL, repository, version) +} + +func (r *registryVersionResolver) resolve(registryURL, repository, version string) (string, error) { credFunc, err := registry.NewCredentialFunc() if err != nil { credFunc = nil @@ -46,11 +54,15 @@ func (r *registryVersionResolver) ResolveLatestVersion(registryURL, repository s client := registry.NewClient(registryURL, credFunc) ctx, cancel := context.WithTimeout(context.Background(), r.timeout) defer cancel() - _, version, err := client.DefinitionVersion(ctx, repository) + lookup := repository + if version != "" { + lookup = repository + ":" + version + } + _, resolved, err := client.DefinitionVersion(ctx, lookup) if err != nil { return "", err } - return version, nil + return resolved, nil } // See FR-039, R44, R51, R52, R55: specs/001-gemara-native-workflow/spec.md diff --git a/internal/cache/sync.go b/internal/cache/sync.go index f1797485..ed1ac010 100644 --- a/internal/cache/sync.go +++ b/internal/cache/sync.go @@ -30,7 +30,12 @@ func (s *Sync) SyncPolicy(ctx context.Context, policyID, version string) error { return fmt.Errorf("policy ID cannot be empty") } - remoteDigest, remoteVersion, err := s.source.DefinitionVersion(ctx, policyID) + lookupRef := policyID + if version != "" && version != "latest" { + lookupRef = policyID + ":" + version + } + + 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)", diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 8b0110c8..83dfe1c2 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -55,6 +55,7 @@ type PolicyGraphResolver interface { // See R55: specs/001-gemara-native-workflow/spec.md type VersionResolver interface { ResolveLatestVersion(registry, repository string) (version string, err error) + ResolveVersion(registry, repository, version string) (string, error) } const registryTimeout = 5 * time.Second @@ -252,11 +253,23 @@ func CheckPolicyVersions(cfg *complytime.WorkspaceConfig, cacheDir string, versi latestVersion, err := versionResolver.ResolveLatestVersion(ref.Registry, ref.Repository) if err != nil { + if ref.Version != "" { + _, pinnedErr := versionResolver.ResolveVersion(ref.Registry, ref.Repository, ref.Version) + if pinnedErr == nil { + results = append(results, CheckResult{ + Name: fmt.Sprintf("policy/%s", eid), + Status: StatusPass, + Message: fmt.Sprintf("%s (pinned — latest tag unavailable for staleness check)", cachedState.Version), + Blocking: false, + }) + continue + } + } unreachable[ref.Registry] = true results = append(results, CheckResult{ Name: fmt.Sprintf("registry/%s", ref.Registry), Status: StatusWarn, - Message: "unreachable — version check skipped", + Message: fmt.Sprintf("unreachable: %v", err), Blocking: false, }) continue diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index b85dc82a..21ed2617 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -17,16 +17,20 @@ import ( // --- Mock VersionResolver --- type mockVersionResolver struct { - versions map[string]string // "registry|repo" -> version - unreachable map[string]bool // registry -> true - errOnResolve map[string]error // "registry|repo" -> error + versions map[string]string // "registry|repo" -> latest version + pinnedVersions map[string]string // "registry|repo|version" -> resolved version + unreachable map[string]bool // registry -> true + errOnResolve map[string]error // "registry|repo" -> error + latestMissing map[string]bool // registry -> true (reachable but no latest tag) } func newMockVersionResolver() *mockVersionResolver { return &mockVersionResolver{ - versions: make(map[string]string), - unreachable: make(map[string]bool), - errOnResolve: make(map[string]error), + versions: make(map[string]string), + pinnedVersions: make(map[string]string), + unreachable: make(map[string]bool), + errOnResolve: make(map[string]error), + latestMissing: make(map[string]bool), } } @@ -34,6 +38,9 @@ func (m *mockVersionResolver) ResolveLatestVersion(registry, repository string) if m.unreachable[registry] { return "", fmt.Errorf("connection refused") } + if m.latestMissing[registry] { + return "", fmt.Errorf("OCI version resolution failed for %s/%s:latest: not found", registry, repository) + } key := registry + "|" + repository if err, ok := m.errOnResolve[key]; ok { return "", err @@ -44,6 +51,17 @@ func (m *mockVersionResolver) ResolveLatestVersion(registry, repository string) return "", fmt.Errorf("not found: %s/%s", registry, repository) } +func (m *mockVersionResolver) ResolveVersion(registry, repository, version string) (string, error) { + if m.unreachable[registry] { + return "", fmt.Errorf("connection refused") + } + key := registry + "|" + repository + "|" + version + if v, ok := m.pinnedVersions[key]; ok { + return v, nil + } + return "", fmt.Errorf("not found: %s/%s:%s", registry, repository, version) +} + // --- Mock PolicyGraphResolver --- type mockPolicyGraphResolver struct { @@ -231,6 +249,76 @@ func TestCheckPolicyVersions_RegistryUnreachable(t *testing.T) { if results[0].Status != StatusWarn { t.Errorf("expected warn, got %s", results[0].Status) } + if !strings.Contains(results[0].Message, "connection refused") { + t.Errorf("expected actual error in message, got %q", results[0].Message) + } +} + +func TestCheckPolicyVersions_LatestMissing_PinnedResolves(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@v1.0.0"}, + }, + } + + vr := newMockVersionResolver() + vr.latestMissing["reg.io"] = true + vr.pinnedVersions["reg.io|policies/nist|v1.0.0"] = "v1.0.0" + + results := CheckPolicyVersions(cfg, tmpDir, vr) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d: %+v", len(results), results) + } + if results[0].Status != StatusPass { + t.Errorf("expected pass for reachable registry with pinned version, 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 tag unavailable") { + t.Errorf("expected 'latest tag unavailable' in message, got %q", results[0].Message) + } +} + +func TestCheckPolicyVersions_LatestMissing_NoPinnedVersion(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.latestMissing["reg.io"] = true + + results := CheckPolicyVersions(cfg, tmpDir, vr) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d: %+v", len(results), results) + } + 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) + } } func TestCheckPolicyVersions_BadCacheState(t *testing.T) { diff --git a/internal/registry/client.go b/internal/registry/client.go index 6d31bf2a..95208def 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -53,7 +53,10 @@ func (c *Client) DefinitionVersion(ctx context.Context, modulePath string) (stri return c.fetcher.DefinitionVersion(ctx, modulePath) } - ref := fmt.Sprintf("%s/%s:latest", c.registryHost(), modulePath) + ref := fmt.Sprintf("%s/%s", c.registryHost(), modulePath) + if !strings.Contains(modulePath, ":") && !strings.Contains(modulePath, "@") { + ref += ":latest" + } digest, version, err := c.fetchVersion(ctx, ref) if err != nil { return "", "", fmt.Errorf("failed to fetch version for %s: %w", modulePath, err)