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
11 changed files with 258 additions and 803 deletions
|
|
@ -313,7 +313,10 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name"
|
|||
|
||||
The identifier format is `registry/namespace/repository:tag` (e.g., `docker.io/user/app:1.0.0` or `ghcr.io/user/app:1.0.0`). The version can also be specified as a digest.
|
||||
|
||||
The official MCP registry currently supports Docker Hub (`docker.io`) and GitHub Container Registry (`ghcr.io`).
|
||||
The official MCP registry supports:
|
||||
- Docker Hub (`docker.io`)
|
||||
- GitHub Container Registry (`ghcr.io`)
|
||||
- Google Artifact Registry (any `*.pkg.dev` domain)
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -36,9 +36,12 @@ Only trusted public registries are supported. Private registries and alternative
|
|||
|
||||
**Supported registries:**
|
||||
- **NPM**: `https://registry.npmjs.org` only
|
||||
- **PyPI**: `https://pypi.org` only
|
||||
- **PyPI**: `https://pypi.org` only
|
||||
- **NuGet**: `https://api.nuget.org` only
|
||||
- **Docker/OCI**: `https://docker.io` only
|
||||
- **Docker/OCI**:
|
||||
- Docker Hub (`docker.io`)
|
||||
- GitHub Container Registry (`ghcr.io`)
|
||||
- Google Artifact Registry (`*.pkg.dev`)
|
||||
- **MCPB**: `https://github.com` releases and `https://gitlab.com` releases only
|
||||
|
||||
## `_meta` Namespace Restrictions
|
||||
|
|
|
|||
14
go.mod
14
go.mod
|
|
@ -6,10 +6,11 @@ require (
|
|||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/coreos/go-oidc/v3 v3.16.0
|
||||
github.com/danielgtaylor/huma/v2 v2.34.1
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/go-containerregistry v0.20.6
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0
|
||||
|
|
@ -25,7 +26,11 @@ require (
|
|||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/docker/cli v28.2.2+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
|
|
@ -34,14 +39,19 @@ require (
|
|||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/otlptranslator v0.0.2 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/rs/cors v1.11.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
|
|
|
|||
25
go.sum
25
go.sum
|
|
@ -4,6 +4,8 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m
|
|||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ=
|
||||
|
|
@ -11,8 +13,12 @@ github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNm
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
|
||||
github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
|
||||
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
|
|
@ -24,6 +30,8 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v
|
|||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
|
||||
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
|
||||
|
|
@ -44,10 +52,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
|
|
@ -66,11 +80,15 @@ github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
|||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
|
||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
|
||||
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 h1:PeBoRj6af6xMI7qCupwFvTbbnd49V7n5YpG6pg8iDYQ=
|
||||
|
|
@ -99,6 +117,7 @@ golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
|||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
|
|
@ -111,3 +130,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
package registries
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultOCINamespace is the default namespace for official images
|
||||
defaultOCINamespace = "library"
|
||||
)
|
||||
|
||||
// OCIReference represents a parsed OCI image reference
|
||||
type OCIReference struct {
|
||||
Registry string // e.g., "ghcr.io", "docker.io"
|
||||
Namespace string // e.g., "owner", "library"
|
||||
Image string // e.g., "repo"
|
||||
Tag string // e.g., "v1.0.0", "latest" (optional)
|
||||
Digest string // e.g., "sha256:abc..." (optional)
|
||||
}
|
||||
|
||||
// ParseOCIReference parses a canonical OCI image reference using github.com/distribution/reference.
|
||||
// Supported formats:
|
||||
// - registry/namespace/image:tag
|
||||
// - registry/namespace/image@digest
|
||||
// - registry/namespace/image:tag@digest
|
||||
// - namespace/image:tag (defaults to docker.io)
|
||||
// - image:tag (defaults to docker.io/library)
|
||||
func ParseOCIReference(ref string) (*OCIReference, error) {
|
||||
if ref == "" {
|
||||
return nil, fmt.Errorf("OCI reference cannot be empty")
|
||||
}
|
||||
|
||||
// Parse using distribution/reference - normalizes short forms to canonical
|
||||
named, err := reference.ParseNormalizedNamed(ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid OCI reference format: %w", err)
|
||||
}
|
||||
|
||||
result := &OCIReference{}
|
||||
|
||||
// Extract registry (domain)
|
||||
result.Registry = reference.Domain(named)
|
||||
|
||||
// Extract path (namespace/image or just image)
|
||||
path := reference.Path(named)
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
// Parse namespace and image from path
|
||||
if len(parts) == 1 {
|
||||
// Single part: library/image (docker.io default namespace)
|
||||
result.Namespace = defaultOCINamespace
|
||||
result.Image = parts[0]
|
||||
} else {
|
||||
// Multiple parts: namespace/image or org/team/image
|
||||
result.Namespace = strings.Join(parts[:len(parts)-1], "/")
|
||||
result.Image = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// Extract tag if present
|
||||
if tagged, ok := named.(reference.Tagged); ok {
|
||||
result.Tag = tagged.Tag()
|
||||
}
|
||||
|
||||
// Extract digest if present
|
||||
if digested, ok := named.(reference.Digested); ok {
|
||||
result.Digest = digested.Digest().String()
|
||||
}
|
||||
|
||||
// Validate we have either a tag or digest (required for MCP registry)
|
||||
if result.Tag == "" && result.Digest == "" {
|
||||
return nil, fmt.Errorf("OCI reference must include either a tag or digest: %s", ref)
|
||||
}
|
||||
|
||||
// Default tag to "latest" if only digest is provided (for display purposes)
|
||||
// Note: when pulling by digest, the tag is ignored by registries
|
||||
if result.Tag == "" && result.Digest != "" {
|
||||
result.Tag = "latest"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// String returns the canonical string representation of the OCI reference
|
||||
func (r *OCIReference) String() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(r.Registry)
|
||||
sb.WriteString("/")
|
||||
sb.WriteString(r.Namespace)
|
||||
sb.WriteString("/")
|
||||
sb.WriteString(r.Image)
|
||||
|
||||
if r.Tag != "" {
|
||||
sb.WriteString(":")
|
||||
sb.WriteString(r.Tag)
|
||||
}
|
||||
|
||||
if r.Digest != "" {
|
||||
sb.WriteString("@")
|
||||
sb.WriteString(r.Digest)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetRegistryBaseURL returns the full registry URL (e.g., "https://docker.io" or "https://ghcr.io")
|
||||
func (r *OCIReference) GetRegistryBaseURL() string {
|
||||
switch r.Registry {
|
||||
case "docker.io", "registry.docker.io", "index.docker.io":
|
||||
return "https://docker.io"
|
||||
case "ghcr.io":
|
||||
return "https://ghcr.io"
|
||||
default:
|
||||
return "https://" + r.Registry
|
||||
}
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
package registries_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/validators/registries"
|
||||
)
|
||||
|
||||
func TestParseOCIReference(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want *registries.OCIReference
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "full reference with tag",
|
||||
input: "ghcr.io/owner/repo:v1.0.0",
|
||||
want: ®istries.OCIReference{
|
||||
Registry: "ghcr.io",
|
||||
Namespace: "owner",
|
||||
Image: "repo",
|
||||
Tag: "v1.0.0",
|
||||
Digest: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full reference with digest only",
|
||||
input: "ghcr.io/owner/repo@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
want: ®istries.OCIReference{
|
||||
Registry: "ghcr.io",
|
||||
Namespace: "owner",
|
||||
Image: "repo",
|
||||
Tag: "latest", // Default when only digest provided
|
||||
Digest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full reference with tag and digest",
|
||||
input: "ghcr.io/owner/repo:v1.0.0@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
want: ®istries.OCIReference{
|
||||
Registry: "ghcr.io",
|
||||
Namespace: "owner",
|
||||
Image: "repo",
|
||||
Tag: "v1.0.0",
|
||||
Digest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "docker.io short form",
|
||||
input: "owner/repo:latest",
|
||||
want: ®istries.OCIReference{
|
||||
Registry: "docker.io",
|
||||
Namespace: "owner",
|
||||
Image: "repo",
|
||||
Tag: "latest",
|
||||
Digest: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "docker.io library image",
|
||||
input: "postgres:16",
|
||||
want: ®istries.OCIReference{
|
||||
Registry: "docker.io",
|
||||
Namespace: "library",
|
||||
Image: "postgres",
|
||||
Tag: "16",
|
||||
Digest: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "docker.io library image with digest",
|
||||
input: "postgres@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
want: ®istries.OCIReference{
|
||||
Registry: "docker.io",
|
||||
Namespace: "library",
|
||||
Image: "postgres",
|
||||
Tag: "latest",
|
||||
Digest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi-level namespace",
|
||||
input: "ghcr.io/org/team/repo:v2.0.0",
|
||||
want: ®istries.OCIReference{
|
||||
Registry: "ghcr.io",
|
||||
Namespace: "org/team",
|
||||
Image: "repo",
|
||||
Tag: "v2.0.0",
|
||||
Digest: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty reference",
|
||||
input: "",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "no tag or digest",
|
||||
input: "ghcr.io/owner/repo",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid digest format",
|
||||
input: "ghcr.io/owner/repo@md5:abc123",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid digest length",
|
||||
input: "ghcr.io/owner/repo@sha256:abc123",
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := registries.ParseOCIReference(tt.input)
|
||||
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Errorf("registries.ParseOCIReference() expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("registries.ParseOCIReference() unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if got.Registry != tt.want.Registry {
|
||||
t.Errorf("Registry = %v, want %v", got.Registry, tt.want.Registry)
|
||||
}
|
||||
if got.Namespace != tt.want.Namespace {
|
||||
t.Errorf("Namespace = %v, want %v", got.Namespace, tt.want.Namespace)
|
||||
}
|
||||
if got.Image != tt.want.Image {
|
||||
t.Errorf("Image = %v, want %v", got.Image, tt.want.Image)
|
||||
}
|
||||
if got.Tag != tt.want.Tag {
|
||||
t.Errorf("Tag = %v, want %v", got.Tag, tt.want.Tag)
|
||||
}
|
||||
if got.Digest != tt.want.Digest {
|
||||
t.Errorf("Digest = %v, want %v", got.Digest, tt.want.Digest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIReference_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ref *registries.OCIReference
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full reference with tag",
|
||||
ref: ®istries.OCIReference{
|
||||
Registry: "ghcr.io",
|
||||
Namespace: "owner",
|
||||
Image: "repo",
|
||||
Tag: "v1.0.0",
|
||||
},
|
||||
want: "ghcr.io/owner/repo:v1.0.0",
|
||||
},
|
||||
{
|
||||
name: "full reference with digest",
|
||||
ref: ®istries.OCIReference{
|
||||
Registry: "ghcr.io",
|
||||
Namespace: "owner",
|
||||
Image: "repo",
|
||||
Tag: "latest",
|
||||
Digest: "sha256:abc123",
|
||||
},
|
||||
want: "ghcr.io/owner/repo:latest@sha256:abc123",
|
||||
},
|
||||
{
|
||||
name: "docker.io library image",
|
||||
ref: ®istries.OCIReference{
|
||||
Registry: "docker.io",
|
||||
Namespace: "library",
|
||||
Image: "postgres",
|
||||
Tag: "16",
|
||||
},
|
||||
want: "docker.io/library/postgres:16",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.ref.String(); got != tt.want {
|
||||
t.Errorf("OCIReference.String() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIReference_GetRegistryBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
registry string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "docker.io",
|
||||
registry: "docker.io",
|
||||
want: "https://docker.io",
|
||||
},
|
||||
{
|
||||
name: "ghcr.io",
|
||||
registry: "ghcr.io",
|
||||
want: "https://ghcr.io",
|
||||
},
|
||||
{
|
||||
name: "custom registry",
|
||||
registry: "my-registry.com",
|
||||
want: "https://my-registry.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ref := ®istries.OCIReference{Registry: tt.registry}
|
||||
if got := ref.GetRegistryBaseURL(); got != tt.want {
|
||||
t.Errorf("GetRegistryBaseURL() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -9,169 +9,85 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidateOCI_RealPackages(t *testing.T) {
|
||||
func TestValidateOCI_RegistryAllowlist(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
packageName string
|
||||
version string
|
||||
serverName string
|
||||
expectError bool
|
||||
errorMessage string
|
||||
registryURL string
|
||||
name string
|
||||
identifier string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
// Allowed registries - use real public images that exist
|
||||
// These should fail with "missing required annotation" (no MCP label)
|
||||
// NOT with "unsupported registry", "does not exist", or "is private" errors
|
||||
{
|
||||
name: "empty package identifier should fail",
|
||||
packageName: "",
|
||||
version: "latest",
|
||||
serverName: "com.example/test",
|
||||
expectError: true,
|
||||
errorMessage: "package identifier is required for OCI packages",
|
||||
name: "Docker Hub should be allowed",
|
||||
identifier: "docker.io/library/alpine:latest",
|
||||
expectError: true,
|
||||
errorMsg: "missing required annotation",
|
||||
},
|
||||
{
|
||||
name: "empty package version should fail",
|
||||
packageName: "test-image",
|
||||
version: "",
|
||||
serverName: "com.example/test",
|
||||
expectError: true,
|
||||
errorMessage: "package version is required for OCI packages",
|
||||
name: "Docker Hub without explicit registry should default and be allowed",
|
||||
identifier: "library/hello-world:latest",
|
||||
expectError: true,
|
||||
errorMsg: "missing required annotation",
|
||||
},
|
||||
{
|
||||
name: "both empty identifier and version should fail with identifier error first",
|
||||
packageName: "",
|
||||
version: "",
|
||||
serverName: "com.example/test",
|
||||
expectError: true,
|
||||
errorMessage: "package identifier is required for OCI packages",
|
||||
name: "GHCR should be allowed",
|
||||
identifier: "ghcr.io/containerbase/base:latest",
|
||||
expectError: true,
|
||||
errorMsg: "missing required annotation",
|
||||
},
|
||||
{
|
||||
name: "non-existent image should fail",
|
||||
packageName: generateRandomImageName(),
|
||||
version: "latest",
|
||||
serverName: "com.example/test",
|
||||
expectError: true,
|
||||
errorMessage: "not found",
|
||||
name: "Artifact Registry regional should be allowed",
|
||||
identifier: "us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:latest",
|
||||
expectError: true,
|
||||
errorMsg: "missing required annotation",
|
||||
},
|
||||
{
|
||||
name: "real image without MCP annotation should fail",
|
||||
packageName: "nginx", // Popular image without MCP annotation
|
||||
version: "latest",
|
||||
serverName: "com.example/test",
|
||||
expectError: true,
|
||||
errorMessage: "missing required annotation",
|
||||
name: "Artifact Registry multi-region should be allowed",
|
||||
identifier: "us-docker.pkg.dev/berglas/berglas/berglas:latest",
|
||||
expectError: true,
|
||||
errorMsg: "missing required annotation",
|
||||
},
|
||||
{
|
||||
name: "real image with specific tag without MCP annotation should fail",
|
||||
packageName: "redis",
|
||||
version: "7-alpine", // Specific tag
|
||||
serverName: "com.example/test",
|
||||
expectError: true,
|
||||
errorMessage: "missing required annotation",
|
||||
},
|
||||
{
|
||||
name: "namespaced image without MCP annotation should fail",
|
||||
packageName: "hello-world", // Simple image for testing
|
||||
version: "latest",
|
||||
serverName: "com.example/test",
|
||||
expectError: true,
|
||||
errorMessage: "missing required annotation",
|
||||
},
|
||||
{
|
||||
name: "real image with correct MCP annotation should pass",
|
||||
packageName: "domdomegg/airtable-mcp-server",
|
||||
version: "1.7.2",
|
||||
serverName: "io.github.domdomegg/airtable-mcp-server", // This should match the annotation
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "GHCR image without MCP annotation should fail",
|
||||
packageName: "actions/runner", // GitHub's action runner image (real image without MCP annotation)
|
||||
version: "latest",
|
||||
serverName: "com.example/test",
|
||||
expectError: true,
|
||||
errorMessage: "missing required annotation",
|
||||
registryURL: model.RegistryURLGHCR,
|
||||
},
|
||||
{
|
||||
name: "real GHCR image without MCP annotation should fail",
|
||||
packageName: "github/github-mcp-server", // Real GitHub MCP server image
|
||||
version: "main",
|
||||
serverName: "io.github.github/github-mcp-server",
|
||||
expectError: true,
|
||||
errorMessage: "missing required annotation", // Image exists but lacks MCP annotation
|
||||
registryURL: model.RegistryURLGHCR,
|
||||
},
|
||||
{
|
||||
name: "GHCR image with correct MCP annotation should pass",
|
||||
packageName: "nkapila6/mcp-local-rag", // Real MCP server with proper annotation
|
||||
version: "latest",
|
||||
serverName: "io.github.nkapila6/mcp-local-rag",
|
||||
expectError: false,
|
||||
registryURL: model.RegistryURLGHCR,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Skip("Skipping OCI registry tests because we keep hitting DockerHub rate limits")
|
||||
|
||||
pkg := model.Package{
|
||||
RegistryType: model.RegistryTypeOCI,
|
||||
RegistryBaseURL: tt.registryURL,
|
||||
Identifier: tt.packageName,
|
||||
Version: tt.version,
|
||||
}
|
||||
|
||||
err := registries.ValidateOCI(ctx, pkg, tt.serverName)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errorMessage)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOCI_UnsupportedRegistry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with unsupported registry in canonical reference format
|
||||
pkg := model.Package{
|
||||
RegistryType: model.RegistryTypeOCI,
|
||||
Identifier: "unsupported-registry.com/test/image:latest",
|
||||
}
|
||||
|
||||
err := registries.ValidateOCI(ctx, pkg, "com.example/test")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "registry type and base URL do not match")
|
||||
assert.Contains(t, err.Error(), "Expected: https://docker.io or https://ghcr.io")
|
||||
}
|
||||
|
||||
func TestValidateOCI_SupportedRegistries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
identifier string
|
||||
expected bool
|
||||
}{
|
||||
// Disallowed registries
|
||||
{
|
||||
name: "Docker Hub should be supported",
|
||||
identifier: "docker.io/test/image:latest",
|
||||
expected: true,
|
||||
name: "GCR should be rejected",
|
||||
identifier: "gcr.io/test/image:latest",
|
||||
expectError: true,
|
||||
errorMsg: "unsupported OCI registry",
|
||||
},
|
||||
{
|
||||
name: "GHCR should be supported",
|
||||
identifier: "ghcr.io/test/image:latest",
|
||||
expected: true,
|
||||
name: "Quay.io should be rejected",
|
||||
identifier: "quay.io/test/image:latest",
|
||||
expectError: true,
|
||||
errorMsg: "unsupported OCI registry",
|
||||
},
|
||||
{
|
||||
name: "Unsupported registry should fail",
|
||||
identifier: "quay.io/test/image:latest",
|
||||
expected: false,
|
||||
name: "ECR Public should be rejected",
|
||||
identifier: "public.ecr.aws/test/image:latest",
|
||||
expectError: true,
|
||||
errorMsg: "unsupported OCI registry",
|
||||
},
|
||||
{
|
||||
name: "GitLab registry should be rejected",
|
||||
identifier: "registry.gitlab.com/test/image:latest",
|
||||
expectError: true,
|
||||
errorMsg: "unsupported OCI registry",
|
||||
},
|
||||
{
|
||||
name: "Custom registry should be rejected",
|
||||
identifier: "custom-registry.com/test/image:latest",
|
||||
expectError: true,
|
||||
errorMsg: "unsupported OCI registry",
|
||||
},
|
||||
{
|
||||
name: "Harbor registry should be rejected",
|
||||
identifier: "harbor.example.com/test/image:latest",
|
||||
expectError: true,
|
||||
errorMsg: "unsupported OCI registry",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -183,15 +99,13 @@ func TestValidateOCI_SupportedRegistries(t *testing.T) {
|
|||
}
|
||||
|
||||
err := registries.ValidateOCI(ctx, pkg, "com.example/test")
|
||||
if tt.expected {
|
||||
// Should not fail immediately on registry validation
|
||||
// (may fail later due to network/image not found, but not due to unsupported registry)
|
||||
if err != nil {
|
||||
assert.NotContains(t, err.Error(), "registry type and base URL do not match")
|
||||
}
|
||||
} else {
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "registry type and base URL do not match")
|
||||
// Should contain the specific error message
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -224,22 +138,13 @@ func TestValidateOCI_RejectsOldFormat(t *testing.T) {
|
|||
errorMessage: "OCI packages must not have 'version' field",
|
||||
},
|
||||
{
|
||||
name: "OCI package with both old format fields should fail on registryBaseUrl first",
|
||||
pkg: model.Package{
|
||||
RegistryType: model.RegistryTypeOCI,
|
||||
RegistryBaseURL: "https://docker.io",
|
||||
Identifier: "test/image",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
errorMessage: "OCI packages must not have 'registryBaseUrl' field",
|
||||
},
|
||||
{
|
||||
name: "OCI package with canonical format should pass old format validation",
|
||||
name: "OCI package with fileSha256 field should be rejected",
|
||||
pkg: model.Package{
|
||||
RegistryType: model.RegistryTypeOCI,
|
||||
Identifier: "docker.io/test/image:latest",
|
||||
FileSHA256: "abcd1234",
|
||||
},
|
||||
errorMessage: "", // Should pass old format check (will fail later due to image not existing)
|
||||
errorMessage: "OCI packages must not have 'fileSha256' field",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -247,14 +152,81 @@ func TestValidateOCI_RejectsOldFormat(t *testing.T) {
|
|||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := registries.ValidateOCI(ctx, tt.pkg, "com.example/test")
|
||||
|
||||
if tt.errorMessage != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errorMessage)
|
||||
} else if err != nil {
|
||||
// Should not fail with old format error (may fail with other errors like image not found)
|
||||
assert.NotContains(t, err.Error(), "must not have 'registryBaseUrl'")
|
||||
assert.NotContains(t, err.Error(), "must not have 'version'")
|
||||
}
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errorMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOCI_InvalidReferences(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
identifier string
|
||||
}{
|
||||
{
|
||||
name: "invalid characters in reference",
|
||||
identifier: "docker.io/test/image:INVALID SPACE",
|
||||
},
|
||||
{
|
||||
name: "malformed reference",
|
||||
identifier: "not-a-valid-reference::::",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkg := model.Package{
|
||||
RegistryType: model.RegistryTypeOCI,
|
||||
Identifier: tt.identifier,
|
||||
}
|
||||
|
||||
err := registries.ValidateOCI(ctx, pkg, "com.example/test")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid OCI reference")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOCI_EmptyIdentifier(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
pkg := model.Package{
|
||||
RegistryType: model.RegistryTypeOCI,
|
||||
Identifier: "",
|
||||
}
|
||||
|
||||
err := registries.ValidateOCI(ctx, pkg, "com.example/test")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "package identifier is required")
|
||||
}
|
||||
|
||||
func TestValidateOCI_SuccessfulValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with a real MCP server image that has the correct label
|
||||
pkg := model.Package{
|
||||
RegistryType: model.RegistryTypeOCI,
|
||||
Identifier: "ghcr.io/github/github-mcp-server:latest",
|
||||
}
|
||||
|
||||
err := registries.ValidateOCI(ctx, pkg, "io.github.github/github-mcp-server")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateOCI_LabelMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with a real MCP server image but wrong expected server name
|
||||
// This should fail because the label doesn't match
|
||||
pkg := model.Package{
|
||||
RegistryType: model.RegistryTypeOCI,
|
||||
Identifier: "ghcr.io/github/github-mcp-server:latest",
|
||||
}
|
||||
|
||||
err := registries.ValidateOCI(ctx, pkg, "io.github.github/github-mcp-server-mismatch")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ownership validation failed")
|
||||
assert.Contains(t, err.Error(), "Expected annotation")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,11 +22,3 @@ func generateRandomNuGetPackageName() string {
|
|||
}
|
||||
return fmt.Sprintf("NonExistent.Package.%s", hex.EncodeToString(bytes)[:16])
|
||||
}
|
||||
|
||||
func generateRandomImageName() string {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "nonexistent-image-fallback"
|
||||
}
|
||||
return fmt.Sprintf("nonexistent-image-%s", hex.EncodeToString(bytes)[:16])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1628,10 +1628,10 @@ func TestValidate_RegistryTypesAndUrls(t *testing.T) {
|
|||
// Invalid registry types (should fail)
|
||||
{"invalid_maven", "io.github.domdomegg/airtable-mcp-server", "maven", model.RegistryURLNPM, "airtable-mcp-server", "1.7.2", "", true},
|
||||
{"invalid_cargo", "io.github.domdomegg/time-mcp-pypi", "cargo", model.RegistryURLPyPI, "time-mcp-pypi", "1.0.1", "", true},
|
||||
{"invalid_gem", "io.github.domdomegg/airtable-mcp-server", "gem", model.RegistryURLDocker, "domdomegg/airtable-mcp-server", "1.7.2", "", true},
|
||||
{"invalid_gem", "io.github.domdomegg/airtable-mcp-server", "gem", "", "domdomegg/airtable-mcp-server", "1.7.2", "", true},
|
||||
{"invalid_unknown", "io.github.domdomegg/time-mcp-server", "unknown", model.RegistryURLNuGet, "TimeMcpServer", "1.0.2", "", true},
|
||||
{"invalid_blank", "io.github.domdomegg/time-mcp-server", "", model.RegistryURLNuGet, "TimeMcpServer", "1.0.2", "", true},
|
||||
{"invalid_docker", "io.github.domdomegg/airtable-mcp-server", "docker", model.RegistryURLDocker, "domdomegg/airtable-mcp-server", "1.7.2", "", true}, // should be oci
|
||||
{"invalid_docker", "io.github.domdomegg/airtable-mcp-server", "docker", "", "domdomegg/airtable-mcp-server", "1.7.2", "", true}, // should be oci
|
||||
{"invalid_github", "io.github.domdomegg/airtable-mcp-server", "github", model.RegistryURLGitHub, "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb", "1.7.2", "", true}, // should be mcpb
|
||||
|
||||
{"invalid_mix_1", "io.github.domdomegg/time-mcp-server", model.RegistryTypeNuGet, model.RegistryURLNPM, "TimeMcpServer", "1.0.2", "", true},
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ const (
|
|||
const (
|
||||
RegistryURLNPM = "https://registry.npmjs.org"
|
||||
RegistryURLPyPI = "https://pypi.org"
|
||||
RegistryURLDocker = "https://docker.io"
|
||||
RegistryURLGHCR = "https://ghcr.io"
|
||||
RegistryURLNuGet = "https://api.nuget.org"
|
||||
RegistryURLGitHub = "https://github.com"
|
||||
RegistryURLGitLab = "https://gitlab.com"
|
||||
|
|
|
|||
Loading…
Reference in a new issue