Component Version Sync (Registry Discovery)
Sprint 20260527.025 (RP-025-008).
Purpose
The Release Orchestrator pins immutable image digests (e.g. sha256:…) onto every component version it records, so a Release is reproducible regardless of registry mutability (latest drift, tag rewrites, etc.). The sync-versions API discovers tags + digests by walking a container registry and persisting one row per (tenant, component, tag, digest) triple in release_orchestrator.component_versions.
Dispatcher architecture
POST /api/v1/components/{id}/sync-versions
│
▼
ComponentVersionSyncService
│
▼ IRegistryVersionSource
DispatchingRegistryVersionSource ──┐
│ (looks up integration via │
│ HttpIntegrationCatalog; │
│ forwards inbound │
│ X-StellaOps-TenantId + │
│ X-Tenant-Id headers per │
│ Sprint 026 GAP-6 fix) │
▼ │
switch on IntegrationProvider: │
│
Harbor ─► HarborRegistryVersionStrategy
(existing Sprint 013 body; calls
Platform's /api/v1/registries/images/digests
which proxies Harbor's /api/v2.0/projects/…/artifacts)
│
DockerHub, GitLabCR, Ecr, Gcr, Acr, Quay, │
Artifactory, Nexus, Custom, <unknown> ─► DockerV2RegistryVersionStrategy
(Sprint 025: manual aggregation via
StellaOps.Integrations.DockerV2 client)
DockerV2 strategy: manual aggregation
For any registry that speaks the OCI Distribution v2 spec:
GET /v2/{repo}/tags/list(paginated viaLinkheader, capped atMaxPagesToFetch).- For each tag, concurrent
HEAD /v2/{repo}/manifests/{tag}(concurrency =SemaphoreSlim(MaxConcurrency), default 8) to readDocker-Content-Digest. - If
Content-Typeis a manifest list / OCI image index, recurse into the platform-pick manifest (defaultlinux/amd64, seeDockerV2ManifestSelectorOptions) and return that manifest’s digest. - Optionally
GET /v2/{repo}/manifests/{tag}body for OCI annotations (org.opencontainers.image.revision,…created, etc.) to enrich the persistedsourceCommitSha. - Skip tags whose digest cannot be resolved (fail-closed; no garbage rows).
2026-05-31 update: label enrichment now reads image config-blob labels in addition to manifest annotations. For each resolved tag, the DockerV2 client can fetch GET /v2/{repo}/blobs/{config.digest} and merges config.Labels over root annotations so GitLab labels such as CI_COMMIT_SHA, CI_PROJECT_URL, and CI_COMMIT_REF_NAME are available to sync-versions.
Cost model per sync call:
1tag-list page (× N if paginated; default capMaxPagesToFetch=100).THEAD calls, whereT= number of returned tags.- Up to
Tadditional GET calls if label enrichment is on. - Throughput cap:
MaxConcurrency(default 8).
GitLab CR worst case for a typical customer service with ~10 active tags × 5 envs synced per component × 200 components is ~200 × 5 × (1 + 10) ≈ 11,000 HEAD calls per full sync. GitLab CR documents 300 req/min by default — AdaptiveThrottlingHandler (src/__Libraries/StellaOps.SourceFetch.Http/AdaptiveThrottlingHandler.cs) is available to plug on the typed HttpClient if 429s are observed; not enabled by default.
Supported registry types
| Provider | Strategy | Auth | Tested |
|---|---|---|---|
| Harbor | HarborRegistryVersionStrategy | inherited from Platform RegistrySearchEndpoints (Bearer to Harbor API v2.0) | Existing Sprint 013 coverage; unchanged |
| Docker Hub | DockerV2RegistryVersionStrategy | Anonymous probe + Basic/Bearer challenge dance | Unit (LoopbackHttpFixture) |
| GitLab Container Registry | DockerV2RegistryVersionStrategy | JWT-bearer challenge response: 401 from registry.example → POST gitlab.example/jwt/auth with PAT (basic) → short-lived JWT → retry with Authorization: Bearer <JWT> | Unit (mirrors plugin Bearer-challenge test from Sprint 013) |
| ECR / GCR / ACR / GHCR / Quay / Artifactory / Nexus | DockerV2RegistryVersionStrategy | Same Bearer/Basic challenge dance (each registry’s auth realm is registry-specific) | Unit (LoopbackHttpFixture exercises the generic dance) |
| Docker Distribution v2 with htpasswd basic-auth | DockerV2RegistryVersionStrategy (fallback for Custom/unknown providers) | Basic auth directly from operator-supplied user:password | Unit |
| Anything else | DockerV2RegistryVersionStrategy (fallback) | Anonymous if no credential; logs “v2 probe failed” if registry returns 5xx | Logged + persists zero rows |
Manifest-list (image index) handling
When a tag resolves to a Docker manifest list (application/vnd.docker.distribution.manifest.list.v2+json) or OCI image index (application/vnd.oci.image.index.v1+json), DockerV2ManifestSelector.SelectPlatformDigest picks the descriptor matching Os=linux, Architecture=amd64 by default and recurses into that manifest. The single-platform digest is what gets persisted, NOT the index digest — this matches what Kubernetes / Compose / Ansible actually deploy at runtime.
SCM provenance enrichment
Scanner delivery source verified against commit
163f35a2ceon 2026-10-03. Re-verify from the repository root withrg -n 'ScmInfoAvailableTrigger|ReleaseOrchestratorEvents' src/Scanner/StellaOps.Scanner.Worker/Options/ScannerWorkerOptions.cs src/Scanner/StellaOps.Scanner.Worker/Program.cs. Installation activation and successful processing require the checks in the event guide.
The Scanner consumer is disabled by default. When configured, it reads the authenticated tenant stream over the owner API and persists its checkpoint in Scanner’s database. It does not poll release_orchestrator.domain_events directly. See Release Orchestrator event consumption for the tenant allowlist, client credentials, retained-event import and recovery contract.
sync-versions stores SCM provenance on the digest-pinned version row when registry labels expose it:
- Commit:
org.opencontainers.image.revision,CI_COMMIT_SHA,CI_COMMIT_SHORT_SHA,GITHUB_SHA,SOURCE_COMMIT. - Repository:
org.opencontainers.image.source,CI_PROJECT_URL,CI_REPOSITORY_URL,GITHUB_REPOSITORY,BUILD_REPOSITORY_URI,SOURCE_REPOSITORY_URL,GIT_URL. - Branch/ref:
org.opencontainers.image.ref.name,CI_COMMIT_REF_NAME,CI_COMMIT_BRANCH,GITHUB_REF_NAME,BUILD_SOURCEBRANCHNAME,SOURCE_BRANCH.
The immutable digest remains the release identity. sourceCommitSha, sourceRepoUrl, and sourceBranch are provenance fields used by downstream analysis. If an image has a commit label but no repository label, operators can set missing SCM values with PATCH /api/v1/components/{componentId}/versions/{versionId}. Manual values are marked with scmManuallySet=true and survive later registry re-syncs. When a row first becomes complete (sourceRepoUrl + sourceCommitSha), Release Orchestrator emits ComponentVersionScmInfoAvailable once for downstream call-path computation. Scanner.Worker consumes that event via Scanner:Worker:ScmInfoAvailableTrigger, creates or reuses a deterministic pending source-backed scan, and enqueues it with repository and commit metadata so call-path analysis can move from computing to a resolved reachability state.
Follow-up (deferred from Sprint 025): make platform-pick configurable per component (e.g., linux/arm64 for a customer’s ARM nodes). Today it is a process-wide option (DockerV2ManifestSelectorOptions.DefaultPlatform). Recommended fix: add an optional platform field on Component and thread it through DockerV2RegistryVersionStrategy.ListVersionsAsync.
Credential resolution
DockerV2RegistryVersionStrategy calls IRegistryCredentialResolver.ResolveAsync(integration) to obtain a username:password string (or username:PAT for GitLab CR) that the DockerV2AuthHandler uses for the Basic header / JWT-bearer dance.
Two implementations:
NullRegistryCredentialResolver(test stub, currently wired by the production orchestrator atsrc/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs:191): returns null → anonymous calls. Production result: every credentialed registry returns 401 (see GAP-8 below).- Vault-backed resolver: NOT YET IMPLEMENTED. The Sprint 025 RP-025-005 Decisions deferred this to RP-025-007 cutover; the cutover did not author it. The 2026-05-26 live RP-025-007 retry hit STOP-condition because all 3 registry test syncs returned 0 versions due to this gap.
GAP-8 — Vault-backed IRegistryCredentialResolver follow-up
The strategy is design-complete; only the secret-dereference wire is missing. Two paths:
- Add
VaultRegistryCredentialResolverin the orchestrator that depends onStellaOps.Integrations.Vault(or a factored shared library) and reads each integration’sAuthRefUridirectly. RequiresVAULT_ADDR+VAULT_TOKENon the orchestrator container (additional surface for the Vault dependency). - Add
HttpRegistryCredentialResolverthat calls a new integrations-web endpointPOST /api/v1/integrations/{id}/resolve-credentials. integrations-web already has Vault wired (Sprint 022 GAP-2 fix), so the dereference stays server-side. Recommended path: keeps Vault confined to one service.
Either fix also requires populating IntegrationCatalogEntry.AuthRefUri in HttpIntegrationCatalog.GetIntegrationAsync (currently hardcoded to null at src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/HttpIntegrationCatalog.cs:78).
Tenant isolation
Per Sprint 026 RP-026-001, the orchestrator’s HttpIntegrationCatalog forwards inbound X-StellaOps-TenantId + X-Tenant-Id headers to integrations-web. This lets the orchestrator look up integrations registered against tenants other than the bearer’s home tenant (cross-tenant sync). Two operational prerequisites for this to actually work end-to-end:
- Gateway
IdentityHeaderPolicyOptions.EnableTenantOverride = trueon the orchestrator→integrations route. - The orchestrator’s service principal’s
AllowedTenantsclaim covers the target tenants.
If either is missing, the gateway strips the forwarded header and integrations-web sees requestedTenant=default; admin tokens still bypass this for privileged reads but the integration’s tenant scope is logged as cross-tenant.
Live evidence (2026-05-26 RP-025-007 retry, BLOCKED on GAP-8)
| Test | Tenant | Integration | Endpoint | HTTP | Versions | Root cause |
|---|---|---|---|---|---|---|
| 1 | internal | registry-primary (00000000-…) | https://registry.example.com/ (GitLab CR) | 200 | 0 | DockerV2 anonymous probe → 401; JWT-bearer dance needs PAT from secret/app/gitlab-pat#token |
| 2 | partner | registry-partner-internal (00000001-…) | https://partner-registry.example.com/ (htpasswd basic) | 200 | 0 | DockerV2 anonymous probe → 401; basic-auth creds at secret/partner-{env}#DOCKER_REGISTRY_* |
| 3 | partner | registry-primary (00000002-…) | https://registry.example.com/ (GitLab CR, partner-namespaced) | 200 | 0 | Same as Test 1 with partner tenant routing |
Evidence: the WS-7 step-2 per-registry sync run recorded under the operator onboarding scripts in devops/agents-targets/ (internal; not published).
References
- Sprint 20260527.025 dossier:
docs/implplan/SPRINT_20260527_025_Registry_plugin_docker_v2_basic_jwt.md - Sprint 20260526.022 WS-7 customer-onboarding dossier, under
docs/implplan/(internal) - Sprint 20260527.026 (GAP-6 fix):
docs/implplan/SPRINT_20260527_026_Multi_tenant_integration_header_forwarding.md - Sprint 013 (component+version registry): archived dossier
IRegistryVersionSourceinterface:src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/IRegistryVersionSource.csIRegistryVersionStrategyinterface:src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/IRegistryVersionStrategy.csDockerV2RegistryVersionStrategy:src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/DockerV2RegistryVersionStrategy.cs- DockerV2 shared library:
src/Integrations/__Libraries/StellaOps.Integrations.DockerV2/ - Integrations connector pattern:
src/Integrations/AGENTS.md
