Skip to content
4 changes: 4 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,11 @@ tasks:
desc: Start Directory server
dir: server/cmd
env:
# Local development settings:
# - Faster publication scheduling for quicker feedback
# - OASF validation disabled (no external schema dependency)
DIRECTORY_SERVER_PUBLICATION_SCHEDULER_INTERVAL: 1s
DIRECTORY_SERVER_OASF_API_VALIDATION_DISABLE: "true"
cmds:
- defer: { task: server:store:stop }
- task: server:store:start
Expand Down
3 changes: 3 additions & 0 deletions server/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,9 @@ func LoadConfig() (*Config, error) {
_ = v.BindEnv("store.provider")
v.SetDefault("store.provider", store.DefaultProvider)

_ = v.BindEnv("store.oci.type")
v.SetDefault("store.oci.type", string(oci.DefaultRegistryType))

_ = v.BindEnv("store.oci.local_dir")
v.SetDefault("store.oci.local_dir", "")

Expand Down
3 changes: 3 additions & 0 deletions server/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func TestConfig(t *testing.T) {
"DIRECTORY_SERVER_LISTEN_ADDRESS": "example.com:8889",
"DIRECTORY_SERVER_OASF_API_VALIDATION_SCHEMA_URL": "https://custom.schema.url",
"DIRECTORY_SERVER_STORE_PROVIDER": "provider",
"DIRECTORY_SERVER_STORE_OCI_TYPE": "ghcr",
"DIRECTORY_SERVER_STORE_OCI_LOCAL_DIR": "local-dir",
"DIRECTORY_SERVER_STORE_OCI_REGISTRY_ADDRESS": "example.com:5001",
"DIRECTORY_SERVER_STORE_OCI_REPOSITORY_NAME": "test-dir",
Expand Down Expand Up @@ -77,6 +78,7 @@ func TestConfig(t *testing.T) {
Store: store.Config{
Provider: "provider",
OCI: oci.Config{
Type: oci.RegistryTypeGHCR,
LocalDir: "local-dir",
RegistryAddress: "example.com:5001",
RepositoryName: "test-dir",
Expand Down Expand Up @@ -152,6 +154,7 @@ func TestConfig(t *testing.T) {
Store: store.Config{
Provider: store.DefaultProvider,
OCI: oci.Config{
Type: oci.DefaultRegistryType,
RegistryAddress: oci.DefaultRegistryAddress,
RepositoryName: oci.DefaultRepositoryName,
AuthConfig: oci.AuthConfig{
Expand Down
16 changes: 8 additions & 8 deletions server/controller/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,22 +47,22 @@ func (s *signCtrl) Verify(ctx context.Context, req *signv1.VerifyRequest) (*sign
return s.verify(ctx, req.GetRecordRef().GetCid())
}

// verify attempts zot verification if the store supports it.
// verify attempts signature verification if the store supports it.
func (s *signCtrl) verify(ctx context.Context, recordCID string) (*signv1.VerifyResponse, error) {
// Check if the store supports zot verification
zotStore, ok := s.store.(types.VerifierStore)
// Check if the store supports signature verification
verifierStore, ok := s.store.(types.VerifierStore)
if !ok {
return nil, status.Error(codes.Unimplemented, "zot verification not available in this store configuration") //nolint:wrapcheck
return nil, status.Error(codes.Unimplemented, "signature verification not available in this store configuration") //nolint:wrapcheck
}

signLogger.Debug("Attempting zot verification", "recordCID", recordCID)
signLogger.Debug("Attempting signature verification", "recordCID", recordCID)

verified, err := zotStore.VerifyWithZot(ctx, recordCID)
verified, err := verifierStore.VerifySignature(ctx, recordCID)
if err != nil {
return nil, status.Errorf(codes.Internal, "zot verification failed: %v", err)
return nil, status.Errorf(codes.Internal, "signature verification failed: %v", err)
}

signLogger.Debug("Zot verification completed", "recordCID", recordCID, "verified", verified)
signLogger.Debug("Signature verification completed", "recordCID", recordCID, "verified", verified)

var errMsg string
if !verified {
Expand Down
10 changes: 5 additions & 5 deletions server/store/eventswrap/eventswrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,11 @@ func (s *eventsStore) IsReady(ctx context.Context) bool {
return s.source.IsReady(ctx)
}

// VerifyWithZot delegates to the source store if it supports Zot verification.
// VerifySignature delegates to the source store if it supports signature verification.
// This ensures the wrapper doesn't hide optional methods from the underlying store.
func (s *eventsStore) VerifyWithZot(ctx context.Context, recordCID string) (bool, error) {
// Check if source supports Zot verification
zotStore, ok := s.source.(types.VerifierStore)
func (s *eventsStore) VerifySignature(ctx context.Context, recordCID string) (bool, error) {
// Check if source supports signature verification
verifierStore, ok := s.source.(types.VerifierStore)
if !ok {
// Source doesn't support it - this shouldn't happen with OCI store,
// but handle gracefully
Expand All @@ -112,7 +112,7 @@ func (s *eventsStore) VerifyWithZot(ctx context.Context, recordCID string) (bool

// Delegate to source
//nolint:wrapcheck
return zotStore.VerifyWithZot(ctx, recordCID)
return verifierStore.VerifySignature(ctx, recordCID)
}

// PushReferrer delegates to the source store if it supports referrer operations.
Expand Down
53 changes: 53 additions & 0 deletions server/store/oci/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,57 @@

package config

import "github.com/agntcy/dir/utils/logging"

var logger = logging.Logger("store/oci/config")

// RegistryType defines the type of OCI registry backend.
// Only explicitly tested registries are supported.
type RegistryType string

const (
// RegistryTypeZot uses Zot registry.
RegistryTypeZot RegistryType = "zot"

// RegistryTypeGHCR uses GitHub Container Registry.
RegistryTypeGHCR RegistryType = "ghcr"

// RegistryTypeDockerHub uses Docker Hub registry.
RegistryTypeDockerHub RegistryType = "dockerhub"

// DefaultRegistryType is the default registry type for backward compatibility.
DefaultRegistryType = RegistryTypeZot
)

// IsSupported returns true if the registry type is explicitly supported and tested.
// Logs a warning if an experimental registry type (ghcr, dockerhub) is used.
func (r RegistryType) IsSupported() bool {
switch r {
case RegistryTypeZot:
return true
case RegistryTypeGHCR, RegistryTypeDockerHub:
logger.Warn("Registry type support is experimental and not fully tested. "+
"The default deployment configuration (Zot registry) is not appropriate for this registry type. "+
"Do not use in production.",
"registry_type", string(r))

return true
default:
return false
}
}

const (
DefaultAuthConfigInsecure = true
DefaultRegistryAddress = "127.0.0.1:5000"
DefaultRepositoryName = "dir"
)

type Config struct {
// Type specifies the registry type (zot, ghcr, dockerhub).
// Defaults to "zot" for backward compatibility.
Type RegistryType `json:"type,omitempty" mapstructure:"type"`

// Path to a local directory that will be to hold data instead of remote.
// If this is set to non-empty value, only local store will be used.
LocalDir string `json:"local_dir,omitempty" mapstructure:"local_dir"`
Expand All @@ -28,6 +72,15 @@ type Config struct {
AuthConfig `json:"auth_config,omitempty" mapstructure:"auth_config"`
}

// GetType returns the registry type, defaulting to Zot if not specified.
func (c Config) GetType() RegistryType {
if c.Type == "" {
return DefaultRegistryType
}

return c.Type
}

// AuthConfig represents the configuration for authentication.
type AuthConfig struct {
Insecure bool `json:"insecure" mapstructure:"insecure"`
Expand Down
51 changes: 24 additions & 27 deletions server/store/oci/internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"

corev1 "github.com/agntcy/dir/api/core/v1"
"github.com/agntcy/dir/utils/logging"
Expand Down Expand Up @@ -176,39 +177,35 @@ func (s *store) deleteFromRemoteRepository(ctx context.Context, ref *corev1.Reco

internalLogger.Debug("Starting remote repository deletion", "cid", cid)

var errors []string

// Phase 1: Delete manifest (tags will be cleaned up by OCI GC)
internalLogger.Debug("Phase 1: Deleting manifest", "cid", cid)

manifestDesc, err := s.repo.Resolve(ctx, cid)
if err != nil {
internalLogger.Debug("Failed to resolve manifest during delete (may already be deleted)", "cid", cid, "error", err)
errors = append(errors, fmt.Sprintf("manifest resolve: %v", err))
} else {
if err := repo.Manifests().Delete(ctx, manifestDesc); err != nil {
internalLogger.Warn("Failed to delete manifest", "cid", cid, "error", err)
errors = append(errors, fmt.Sprintf("manifest delete: %v", err))
} else {
internalLogger.Debug("Manifest deleted successfully", "cid", cid, "digest", manifestDesc.Digest.String())
// If manifest doesn't exist, consider it already deleted
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "NOT_FOUND") {
internalLogger.Info("Manifest not found (never existed or deleted already)", "cid", cid)

return nil
}

// Other errors (network, auth, etc.) are internal failures
return status.Errorf(codes.Internal, "failed to resolve manifest for deletion: %v", err)
}

// Phase 2: Skip blob deletion for remote registries (best practice)
// Most remote registries handle blob cleanup via garbage collection
internalLogger.Debug("Phase 2: Skipping blob deletion (handled by registry GC)", "cid", cid)
internalLogger.Info("Blob cleanup skipped for remote registry - will be handled by garbage collection",
"cid", cid,
"note", "This is the recommended approach for remote registries")
if err := repo.Manifests().Delete(ctx, manifestDesc); err != nil {
errStr := err.Error()

// Log summary
if len(errors) > 0 {
// For remote registries, partial failure is common and expected
// Many operations may not be supported, but this is normal
internalLogger.Warn("Partial delete completed with some errors", "cid", cid, "errors", errors)
} else {
internalLogger.Info("Record deletion completed successfully", "cid", cid)
// Check for "operation not supported" errors (e.g., GHCR returns 405)
if strings.Contains(errStr, "405") || strings.Contains(errStr, "unsupported") {
internalLogger.Warn("Registry does not support manifest deletion via OCI API", "cid", cid, "error", err, "hint", "Delete the package through the registry's web UI or native API (e.g., GitHub Packages API for GHCR)")

return status.Errorf(codes.Unimplemented, "registry does not support OCI delete API; use the registry's web UI or native API to delete packages")
}

internalLogger.Warn("Failed to delete manifest", "cid", cid, "error", err)

return status.Errorf(codes.Internal, "failed to delete manifest: %v", err)
}

return nil // Best effort - remote registries have limited delete capabilities
internalLogger.Debug("Manifest deleted successfully", "cid", cid, "digest", manifestDesc.Digest.String())

return nil
}
47 changes: 43 additions & 4 deletions server/store/oci/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ func New(cfg ociconfig.Config) (types.StoreAPI, error) {
}, nil
}

// Validate registry type (logs warning for experimental types like ghcr, dockerhub)
registryType := cfg.GetType()
if !registryType.IsSupported() {
return nil, fmt.Errorf("unsupported registry type: %s", registryType)
}

repo, err := NewORASRepository(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create remote repo: %w", err)
Expand Down Expand Up @@ -405,7 +411,9 @@ func (s *store) Delete(ctx context.Context, ref *corev1.RecordRef) error {

// IsReady checks if the storage backend is ready to serve traffic.
// For local stores, always returns true.
// For remote OCI registries, checks Zot's /readyz endpoint to verify it's ready.
// For remote OCI registries:
// - Zot: checks /readyz endpoint
// - Generic: attempts a ping/tags request to verify connectivity
func (s *store) IsReady(ctx context.Context) bool {
// Local directory stores are always ready
if s.config.LocalDir != "" {
Expand All @@ -415,14 +423,45 @@ func (s *store) IsReady(ctx context.Context) bool {
}

// For remote registries, check connectivity
_, ok := s.repo.(*remote.Repository)
remoteRepo, ok := s.repo.(*remote.Repository)
if !ok {
// Not a remote repository (could be wrapped), assume ready
logger.Debug("Store ready: not a remote repository")

return true
}

// Use the zot utility package to check Zot's readiness
return zot.CheckReadiness(ctx, s.config.RegistryAddress, s.config.Insecure)
// Check readiness based on registry type
switch s.config.GetType() {
case ociconfig.RegistryTypeZot:
// Use the zot utility package to check Zot's readiness
return zot.CheckReadiness(ctx, s.config.RegistryAddress, s.config.Insecure)

case ociconfig.RegistryTypeGHCR, ociconfig.RegistryTypeDockerHub:
// For GHCR/Docker Hub, try to list tags to verify connectivity
// This is a lightweight operation that these registries support
err := remoteRepo.Tags(ctx, "", func(_ []string) error {
return nil // Just checking connectivity, don't need results
})
if err != nil {
// Check if it's a "repository not found" error - that's OK, registry is reachable
errStr := err.Error()
if strings.Contains(errStr, "404") || strings.Contains(errStr, "NAME_UNKNOWN") {
logger.Debug("Store ready: registry reachable, repository may not exist yet")

return true
}

logger.Debug("Store not ready: failed to connect to registry", "error", err)

return false
}

logger.Debug("Store ready", "registry_type", s.config.GetType())

return true

default:
return false
}
}
10 changes: 6 additions & 4 deletions server/store/oci/referrers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"

corev1 "github.com/agntcy/dir/api/core/v1"
ociconfig "github.com/agntcy/dir/server/store/oci/config"
"github.com/agntcy/dir/utils/logging"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"google.golang.org/grpc/codes"
Expand Down Expand Up @@ -47,11 +48,12 @@ func (s *store) PushReferrer(ctx context.Context, recordCID string, referrer *co
// Map API type to internal OCI artifact type
ociArtifactType := apiToOCIType(referrer.GetType())

// If the referrer is a public key, upload it to zot for signature verification
if ociArtifactType == PublicKeyArtifactMediaType {
err := s.uploadPublicKey(ctx, referrer)
// If the referrer is a public key and using Zot registry, upload to Zot's cosign extension
// for signature verification. Other registries only use OCI referrers for public key storage.
if ociArtifactType == PublicKeyArtifactMediaType && s.config.GetType() == ociconfig.RegistryTypeZot {
err := s.uploadPublicKeyToZot(ctx, referrer)
if err != nil {
return status.Errorf(codes.Internal, "failed to upload public key: %v", err)
return status.Errorf(codes.Internal, "failed to upload public key to zot: %v", err)
}
}

Expand Down
26 changes: 22 additions & 4 deletions server/store/oci/signatures.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

corev1 "github.com/agntcy/dir/api/core/v1"
signv1 "github.com/agntcy/dir/api/sign/v1"
ociconfig "github.com/agntcy/dir/server/store/oci/config"
"github.com/agntcy/dir/utils/cosign"
"github.com/agntcy/dir/utils/zot"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
Expand Down Expand Up @@ -41,8 +42,9 @@ func (s *store) pushSignature(ctx context.Context, recordCID string, referrer *c
return nil
}

// uploadPublicKey uploads a public key to zot for signature verification.
func (s *store) uploadPublicKey(ctx context.Context, referrer *corev1.RecordReferrer) error {
// uploadPublicKeyToZot uploads a public key to Zot's cosign extension for signature verification.
// This is only needed for Zot registries - other registries use OCI referrers only.
func (s *store) uploadPublicKeyToZot(ctx context.Context, referrer *corev1.RecordReferrer) error {
referrersLogger.Debug("Uploading public key to zot for signature verification")

// Decode the public key from the referrer
Expand Down Expand Up @@ -155,8 +157,24 @@ func (s *store) convertCosignSignatureToReferrer(blobDesc ocispec.Descriptor, da
return referrer, nil
}

// VerifyWithZot queries zot's verification API to check if a signature is valid.
func (s *store) VerifyWithZot(ctx context.Context, recordCID string) (bool, error) {
// VerifySignature verifies a record signature using the appropriate method
// based on the configured registry type.
func (s *store) VerifySignature(ctx context.Context, recordCID string) (bool, error) {
switch s.config.GetType() {
case ociconfig.RegistryTypeZot:
return s.verifyWithZot(ctx, recordCID)

case ociconfig.RegistryTypeGHCR, ociconfig.RegistryTypeDockerHub:
// TODO: Implement in https://github.com/agntcy/dir/issues/798
return false, fmt.Errorf("signature verification not yet supported for %s registry", s.config.GetType())

default:
return false, fmt.Errorf("unsupported registry type: %s", s.config.GetType())
}
}

// verifyWithZot queries zot's verification API to check if a signature is valid.
func (s *store) verifyWithZot(ctx context.Context, recordCID string) (bool, error) {
verifyOpts := &zot.VerificationOptions{
Config: s.buildZotConfig(),
RecordCID: recordCID,
Expand Down
Loading
Loading