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/Concelier/__Libraries/StellaOps.Concelier.Connector.Common/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
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
