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:

  1. GET /v2/{repo}/tags/list (paginated via Link header, capped at MaxPagesToFetch).
  2. For each tag, concurrent HEAD /v2/{repo}/manifests/{tag} (concurrency = SemaphoreSlim(MaxConcurrency), default 8) to read Docker-Content-Digest.
  3. If Content-Type is a manifest list / OCI image index, recurse into the platform-pick manifest (default linux/amd64, see DockerV2ManifestSelectorOptions) and return that manifest’s digest.
  4. Optionally GET /v2/{repo}/manifests/{tag} body for OCI annotations (org.opencontainers.image.revision, …created, etc.) to enrich the persisted sourceCommitSha.
  5. 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:

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

ProviderStrategyAuthTested
HarborHarborRegistryVersionStrategyinherited from Platform RegistrySearchEndpoints (Bearer to Harbor API v2.0)Existing Sprint 013 coverage; unchanged
Docker HubDockerV2RegistryVersionStrategyAnonymous probe + Basic/Bearer challenge danceUnit (LoopbackHttpFixture)
GitLab Container RegistryDockerV2RegistryVersionStrategyJWT-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 / NexusDockerV2RegistryVersionStrategySame Bearer/Basic challenge dance (each registry’s auth realm is registry-specific)Unit (LoopbackHttpFixture exercises the generic dance)
Docker Distribution v2 with htpasswd basic-authDockerV2RegistryVersionStrategy (fallback for Custom/unknown providers)Basic auth directly from operator-supplied user:passwordUnit
Anything elseDockerV2RegistryVersionStrategy (fallback)Anonymous if no credential; logs “v2 probe failed” if registry returns 5xxLogged + 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 163f35a2ce on 2026-10-03. Re-verify from the repository root with rg -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:

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:

GAP-8 — Vault-backed IRegistryCredentialResolver follow-up

The strategy is design-complete; only the secret-dereference wire is missing. Two paths:

  1. Add VaultRegistryCredentialResolver in the orchestrator that depends on StellaOps.Integrations.Vault (or a factored shared library) and reads each integration’s AuthRefUri directly. Requires VAULT_ADDR + VAULT_TOKEN on the orchestrator container (additional surface for the Vault dependency).
  2. Add HttpRegistryCredentialResolverthat calls a new integrations-web endpoint POST /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:

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)

TestTenantIntegrationEndpointHTTPVersionsRoot cause
1internalregistry-primary (00000000-…)https://registry.example.com/ (GitLab CR)2000DockerV2 anonymous probe → 401; JWT-bearer dance needs PAT from secret/app/gitlab-pat#token
2partnerregistry-partner-internal (00000001-…)https://partner-registry.example.com/ (htpasswd basic)2000DockerV2 anonymous probe → 401; basic-auth creds at secret/partner-{env}#DOCKER_REGISTRY_*
3partnerregistry-primary (00000002-…)https://registry.example.com/ (GitLab CR, partner-namespaced)2000Same 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