Add support for Google Artifact Registry (GAR) (#692)
<!-- Provide a brief summary of your changes --> ## Motivation and Context <!-- Why is this change needed? What problem does it solve? --> The following PR: * Deprecates the existing OCI reference parser * Deprecates the existing OCI way of authenticating and accessing the image manifest * Replaces the above by leveraging go-containerregistry * Introduces support for Google Artifact Registry * Enables us to easily add support for other OCI-compliant registries in the future * Introduces better error handling * Introduces custom context timeouts for validation ## How Has This Been Tested? <!-- Have you tested this in a real application? Which scenarios were tested? --> ## Breaking Changes <!-- Will users need to update their code or configurations? --> ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [ ] My code follows the repository's style guidelines - [ ] New and existing tests pass locally - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions --> Related to #427 --------- Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
This commit is contained in:
parent
31d35d2f77
commit
913147be3a
1 changed files with 258 additions and 803 deletions
|
|
@ -2,79 +2,38 @@ package registries
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
|
||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingIdentifierForOCI = errors.New("package identifier is required for OCI packages")
|
||||
ErrMissingVersionForOCI = errors.New("package version is required for OCI packages")
|
||||
)
|
||||
|
||||
const (
|
||||
dockerIoAPIBaseURL = "https://registry-1.docker.io"
|
||||
ghcrAPIBaseURL = "https://ghcr.io"
|
||||
ErrUnsupportedRegistry = errors.New("unsupported OCI registry")
|
||||
)
|
||||
|
||||
// ErrRateLimited is returned when a registry rate limits our requests
|
||||
var ErrRateLimited = errors.New("rate limited by registry")
|
||||
|
||||
// OCIAuthResponse represents an OCI registry authentication response
|
||||
type OCIAuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// RegistryConfig holds configuration for different OCI registries
|
||||
type RegistryConfig struct {
|
||||
APIBaseURL string
|
||||
AuthURL string
|
||||
Service string
|
||||
Scope string
|
||||
}
|
||||
|
||||
// getRegistryConfig returns the configuration for a specific registry
|
||||
func getRegistryConfig(registryBaseURL, namespace, repo string) *RegistryConfig {
|
||||
switch registryBaseURL {
|
||||
case model.RegistryURLDocker:
|
||||
return &RegistryConfig{
|
||||
APIBaseURL: dockerIoAPIBaseURL,
|
||||
AuthURL: "https://auth.docker.io/token",
|
||||
Service: "registry.docker.io",
|
||||
Scope: fmt.Sprintf("repository:%s/%s:pull", namespace, repo),
|
||||
}
|
||||
case model.RegistryURLGHCR:
|
||||
return &RegistryConfig{
|
||||
APIBaseURL: ghcrAPIBaseURL,
|
||||
AuthURL: fmt.Sprintf("%s/token", ghcrAPIBaseURL),
|
||||
Service: "ghcr.io",
|
||||
Scope: fmt.Sprintf("repository:%s/%s:pull", namespace, repo),
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// OCIManifest represents an OCI image manifest
|
||||
type OCIManifest struct {
|
||||
Manifests []struct {
|
||||
Digest string `json:"digest"`
|
||||
} `json:"manifests,omitempty"`
|
||||
Config struct {
|
||||
Digest string `json:"digest"`
|
||||
} `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// OCIImageConfig represents an OCI image configuration
|
||||
type OCIImageConfig struct {
|
||||
Config struct {
|
||||
Labels map[string]string `json:"Labels"`
|
||||
} `json:"config"`
|
||||
// allowedOCIRegistries defines the list of supported OCI registries.
|
||||
// This can be expanded in the future to support additional public registries.
|
||||
var allowedOCIRegistries = map[string]bool{
|
||||
// Docker Hub (and its various endpoints)
|
||||
"docker.io": true,
|
||||
"registry-1.docker.io": true, // Docker Hub API endpoint
|
||||
"index.docker.io": true, // Docker Hub index
|
||||
// GitHub Container Registry
|
||||
"ghcr.io": true,
|
||||
// Google Artifact Registry (*.pkg.dev pattern handled in isAllowedRegistry)
|
||||
}
|
||||
|
||||
// ValidateOCI validates that an OCI image contains the correct MCP server name annotation.
|
||||
|
|
@ -83,6 +42,11 @@ type OCIImageConfig struct {
|
|||
// - registry/namespace/image@sha256:digest
|
||||
// - registry/namespace/image:tag@sha256:digest
|
||||
// - namespace/image:tag (defaults to docker.io)
|
||||
//
|
||||
// Supported registries:
|
||||
// - Docker Hub (docker.io)
|
||||
// - GitHub Container Registry (ghcr.io)
|
||||
// - Google Artifact Registry (*.pkg.dev)
|
||||
func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) error {
|
||||
if pkg.Identifier == "" {
|
||||
return ErrMissingIdentifierForOCI
|
||||
|
|
@ -99,139 +63,69 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro
|
|||
return fmt.Errorf("OCI packages must not have 'fileSha256' field")
|
||||
}
|
||||
|
||||
// Parse the canonical OCI reference from the identifier
|
||||
ociRef, err := ParseOCIReference(pkg.Identifier)
|
||||
// Parse the OCI reference using go-containerregistry's name package
|
||||
// This handles all the complexity of reference parsing including defaults
|
||||
ref, err := name.ParseReference(pkg.Identifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid OCI reference: %w", err)
|
||||
}
|
||||
|
||||
// Validate that the registry is supported
|
||||
registryBaseURL := ociRef.GetRegistryBaseURL()
|
||||
if err := validateRegistryURL(registryBaseURL); err != nil {
|
||||
return err
|
||||
// Validate that the registry is in the allowlist
|
||||
registry := ref.Context().RegistryStr()
|
||||
if !isAllowedRegistry(registry) {
|
||||
return fmt.Errorf("%w: %s", ErrUnsupportedRegistry, registry)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
// Add explicit timeout to prevent hanging on slow registries
|
||||
// Use a new context with timeout to avoid modifying the caller's context
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get registry configuration
|
||||
registryConfig := getRegistryConfig(registryBaseURL, ociRef.Namespace, ociRef.Image)
|
||||
if registryConfig == nil {
|
||||
return fmt.Errorf("unsupported registry: %s", registryBaseURL)
|
||||
}
|
||||
|
||||
// Determine what to use for manifest lookup: digest if available (most secure), otherwise tag
|
||||
manifestRef := ociRef.Tag
|
||||
if ociRef.Digest != "" {
|
||||
manifestRef = ociRef.Digest
|
||||
}
|
||||
|
||||
// Get the image manifest
|
||||
manifest, err := fetchImageManifest(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, manifestRef)
|
||||
// Fetch the image using anonymous authentication (public images only)
|
||||
// The go-containerregistry library handles:
|
||||
// - OCI auth discovery via WWW-Authenticate headers
|
||||
// - Token negotiation for different registries
|
||||
// - Rate limiting and retries
|
||||
// - Multi-arch manifest resolution
|
||||
img, err := remote.Image(ref, remote.WithAuth(authn.Anonymous), remote.WithContext(timeoutCtx))
|
||||
if err != nil {
|
||||
// Handle rate limiting explicitly - skip validation
|
||||
if errors.Is(err, ErrRateLimited) {
|
||||
log.Printf("Skipping OCI validation for %s due to rate limiting", ociRef.String())
|
||||
return nil
|
||||
// Check if this is a timeout error
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("OCI image validation timed out after 30 seconds for '%s'. The registry may be slow or unreachable", pkg.Identifier)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Get config digest from manifest
|
||||
configDigest, err := getConfigDigestFromManifest(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, manifest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate server name annotation
|
||||
return validateServerNameAnnotation(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, ociRef.Tag, configDigest, serverName)
|
||||
}
|
||||
|
||||
// validateRegistryURL validates that the registry base URL is supported
|
||||
func validateRegistryURL(registryURL string) error {
|
||||
if registryURL != model.RegistryURLDocker && registryURL != model.RegistryURLGHCR {
|
||||
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s or %s",
|
||||
registryURL, model.RegistryTypeOCI, model.RegistryURLDocker, model.RegistryURLGHCR)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchImageManifest fetches the OCI manifest for an image
|
||||
func fetchImageManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, tag string) (*OCIManifest, error) {
|
||||
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", registryConfig.APIBaseURL, namespace, repo, tag)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create manifest request: %w", err)
|
||||
}
|
||||
|
||||
// Get auth token if registry requires it
|
||||
if registryConfig.AuthURL != "" {
|
||||
token, err := getRegistryAuthToken(ctx, client, registryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
|
||||
// Check for specific HTTP status codes
|
||||
var transportErr *transport.Error
|
||||
if errors.As(err, &transportErr) {
|
||||
switch transportErr.StatusCode {
|
||||
case http.StatusTooManyRequests:
|
||||
// Rate limited - skip validation to avoid blocking publishers
|
||||
// This is intentional: we prioritize UX over strict validation during high traffic
|
||||
log.Printf("Skipping OCI validation for %s due to rate limiting", pkg.Identifier)
|
||||
return nil
|
||||
case http.StatusNotFound:
|
||||
return fmt.Errorf("OCI image '%s' does not exist in the registry", pkg.Identifier)
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("OCI image '%s' is private or requires authentication. Only public images are supported", pkg.Identifier)
|
||||
}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return fmt.Errorf("failed to fetch OCI image: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json")
|
||||
req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch OCI manifest: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("OCI image '%s/%s:%s' not found (status: %d)", namespace, repo, tag, resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
// Rate limited, return explicit error
|
||||
log.Printf("Rate limited when accessing OCI image '%s/%s:%s'", namespace, repo, tag)
|
||||
return nil, fmt.Errorf("%w: %s/%s:%s", ErrRateLimited, namespace, repo, tag)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch OCI manifest (status: %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var manifest OCIManifest
|
||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse OCI manifest: %w", err)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// getConfigDigestFromManifest extracts the config digest from an OCI manifest
|
||||
func getConfigDigestFromManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo string, manifest *OCIManifest) (string, error) {
|
||||
// Handle multi-arch images by using first manifest
|
||||
if len(manifest.Manifests) > 0 {
|
||||
// This is a multi-arch image, get the specific manifest
|
||||
specificManifest, err := getSpecificManifest(ctx, client, registryConfig, namespace, repo, manifest.Manifests[0].Digest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get specific manifest: %w", err)
|
||||
}
|
||||
return specificManifest.Config.Digest, nil
|
||||
}
|
||||
|
||||
// For single-arch images, validate we have a config digest
|
||||
if manifest.Config.Digest == "" {
|
||||
return "", fmt.Errorf("manifest missing config digest - invalid or corrupted manifest")
|
||||
}
|
||||
|
||||
return manifest.Config.Digest, nil
|
||||
}
|
||||
|
||||
// validateServerNameAnnotation validates the MCP server name annotation in the image config
|
||||
func validateServerNameAnnotation(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, tag, configDigest, serverName string) error {
|
||||
// Get image config (contains labels)
|
||||
config, err := getImageConfig(ctx, client, registryConfig, namespace, repo, configDigest)
|
||||
// Get the image config which contains labels
|
||||
configFile, err := img.ConfigFile()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get image config: %w", err)
|
||||
}
|
||||
|
||||
mcpName, exists := config.Config.Labels["io.modelcontextprotocol.server.name"]
|
||||
// Validate the MCP server name label
|
||||
if configFile.Config.Labels == nil {
|
||||
return fmt.Errorf("OCI image '%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", pkg.Identifier, serverName)
|
||||
}
|
||||
|
||||
mcpName, exists := configFile.Config.Labels["io.modelcontextprotocol.server.name"]
|
||||
if !exists {
|
||||
return fmt.Errorf("OCI image '%s/%s:%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", namespace, repo, tag, serverName)
|
||||
return fmt.Errorf("OCI image '%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", pkg.Identifier, serverName)
|
||||
}
|
||||
|
||||
if mcpName != serverName {
|
||||
|
|
@ -241,109 +135,19 @@ func validateServerNameAnnotation(ctx context.Context, client *http.Client, regi
|
|||
return nil
|
||||
}
|
||||
|
||||
// getRegistryAuthToken retrieves an authentication token from a registry
|
||||
func getRegistryAuthToken(ctx context.Context, client *http.Client, config *RegistryConfig) (string, error) {
|
||||
if config.AuthURL == "" {
|
||||
return "", nil // No auth required
|
||||
// isAllowedRegistry checks if the given registry is in the allowlist.
|
||||
// It handles registry aliases and wildcard patterns (e.g., *.pkg.dev for Artifact Registry).
|
||||
func isAllowedRegistry(registry string) bool {
|
||||
// Direct match
|
||||
if allowedOCIRegistries[registry] {
|
||||
return true
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf("%s?service=%s&scope=%s", config.AuthURL, config.Service, config.Scope)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create auth request: %w", err)
|
||||
// Check for wildcard patterns
|
||||
// Google Artifact Registry: *.pkg.dev (e.g., us-docker.pkg.dev, europe-west1-docker.pkg.dev)
|
||||
if strings.HasSuffix(registry, ".pkg.dev") {
|
||||
return true
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to request auth token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("auth request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var authResp OCIAuthResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse auth response: %w", err)
|
||||
}
|
||||
|
||||
return authResp.Token, nil
|
||||
}
|
||||
|
||||
// getSpecificManifest retrieves a specific manifest for multi-arch images
|
||||
func getSpecificManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, digest string) (*OCIManifest, error) {
|
||||
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", registryConfig.APIBaseURL, namespace, repo, digest)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create specific manifest request: %w", err)
|
||||
}
|
||||
|
||||
// Get auth token if registry requires it
|
||||
if registryConfig.AuthURL != "" {
|
||||
token, err := getRegistryAuthToken(ctx, client, registryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json")
|
||||
req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch specific manifest: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("specific manifest not found (status: %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var manifest OCIManifest
|
||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse specific manifest: %w", err)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// getImageConfig retrieves the image configuration containing labels
|
||||
func getImageConfig(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, configDigest string) (*OCIImageConfig, error) {
|
||||
configURL := fmt.Sprintf("%s/v2/%s/%s/blobs/%s", registryConfig.APIBaseURL, namespace, repo, configDigest)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, configURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create config request: %w", err)
|
||||
}
|
||||
|
||||
// Get auth token if registry requires it
|
||||
if registryConfig.AuthURL != "" {
|
||||
token, err := getRegistryAuthToken(ctx, client, registryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch image config: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("image config not found (status: %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var config OCIImageConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse image config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue