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/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

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

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