mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
322 lines
9.4 KiB
Go
322 lines
9.4 KiB
Go
package atproto
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Collection names for ATProto records
|
|
const (
|
|
// ManifestCollection is the collection name for container manifests
|
|
ManifestCollection = "io.atcr.manifest"
|
|
|
|
// TagCollection is the collection name for image tags
|
|
TagCollection = "io.atcr.tag"
|
|
|
|
// HoldCollection is the collection name for storage holds (BYOS)
|
|
HoldCollection = "io.atcr.hold"
|
|
|
|
// HoldCrewCollection is the collection name for hold crew (membership)
|
|
HoldCrewCollection = "io.atcr.hold.crew"
|
|
|
|
// SailorProfileCollection is the collection name for user profiles
|
|
SailorProfileCollection = "io.atcr.sailor.profile"
|
|
|
|
// StarCollection is the collection name for repository stars
|
|
StarCollection = "io.atcr.sailor.star"
|
|
)
|
|
|
|
// ManifestRecord represents a container image manifest stored in ATProto
|
|
// This follows the OCI image manifest specification but stored as an ATProto record
|
|
type ManifestRecord struct {
|
|
// Type should be "io.atcr.manifest"
|
|
Type string `json:"$type"`
|
|
|
|
// Repository is the name of the repository (e.g., "myapp")
|
|
Repository string `json:"repository"`
|
|
|
|
// Digest is the content digest (e.g., "sha256:abc123...")
|
|
Digest string `json:"digest"`
|
|
|
|
// HoldEndpoint is the hold service endpoint where blobs are stored
|
|
// This is a historical reference that doesn't change even if user's default hold changes
|
|
HoldEndpoint string `json:"holdEndpoint"`
|
|
|
|
// MediaType is the OCI media type (e.g., "application/vnd.oci.image.manifest.v1+json")
|
|
MediaType string `json:"mediaType"`
|
|
|
|
// SchemaVersion is the OCI schema version (typically 2)
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
|
|
// Config references the image configuration blob
|
|
Config BlobReference `json:"config"`
|
|
|
|
// Layers references the filesystem layers
|
|
Layers []BlobReference `json:"layers"`
|
|
|
|
// Annotations contains arbitrary metadata
|
|
Annotations map[string]string `json:"annotations,omitempty"`
|
|
|
|
// Subject references another manifest (for attestations, signatures, etc.)
|
|
Subject *BlobReference `json:"subject,omitempty"`
|
|
|
|
// ManifestBlob is a reference to the manifest blob stored in ATProto blob storage
|
|
ManifestBlob *ATProtoBlobRef `json:"manifestBlob,omitempty"`
|
|
|
|
// CreatedAt timestamp
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// BlobReference represents a reference to a blob (layer or config)
|
|
// Blobs are stored in S3 and referenced by digest
|
|
type BlobReference struct {
|
|
// MediaType of the blob
|
|
MediaType string `json:"mediaType"`
|
|
|
|
// Digest is the content digest (e.g., "sha256:abc123...")
|
|
Digest string `json:"digest"`
|
|
|
|
// Size in bytes
|
|
Size int64 `json:"size"`
|
|
|
|
// URLs where the blob can be retrieved (S3 URLs)
|
|
URLs []string `json:"urls,omitempty"`
|
|
|
|
// Annotations for the blob
|
|
Annotations map[string]string `json:"annotations,omitempty"`
|
|
}
|
|
|
|
// NewManifestRecord creates a new manifest record from OCI manifest JSON
|
|
func NewManifestRecord(repository, digest string, ociManifest []byte) (*ManifestRecord, error) {
|
|
// Parse the OCI manifest
|
|
var ociData struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
MediaType string `json:"mediaType"`
|
|
Config json.RawMessage `json:"config"`
|
|
Layers []json.RawMessage `json:"layers"`
|
|
Subject json.RawMessage `json:"subject,omitempty"`
|
|
Annotations map[string]string `json:"annotations,omitempty"`
|
|
}
|
|
|
|
if err := json.Unmarshal(ociManifest, &ociData); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
record := &ManifestRecord{
|
|
Type: ManifestCollection,
|
|
Repository: repository,
|
|
Digest: digest,
|
|
MediaType: ociData.MediaType,
|
|
SchemaVersion: ociData.SchemaVersion,
|
|
Annotations: ociData.Annotations,
|
|
// ManifestBlob will be set by the caller after uploading to blob storage
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
// Parse config
|
|
if err := json.Unmarshal(ociData.Config, &record.Config); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse layers
|
|
record.Layers = make([]BlobReference, len(ociData.Layers))
|
|
for i, layer := range ociData.Layers {
|
|
if err := json.Unmarshal(layer, &record.Layers[i]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Parse subject if present
|
|
if len(ociData.Subject) > 0 {
|
|
var subject BlobReference
|
|
if err := json.Unmarshal(ociData.Subject, &subject); err != nil {
|
|
return nil, err
|
|
}
|
|
record.Subject = &subject
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// TagRecord represents a tag pointing to a manifest
|
|
type TagRecord struct {
|
|
// Type should be "io.atcr.tag"
|
|
Type string `json:"$type"`
|
|
|
|
// Repository is the name of the repository
|
|
Repository string `json:"repository"`
|
|
|
|
// Tag is the tag name (e.g., "latest", "v1.0.0")
|
|
Tag string `json:"tag"`
|
|
|
|
// ManifestDigest is the digest of the manifest this tag points to
|
|
ManifestDigest string `json:"manifestDigest"`
|
|
|
|
// UpdatedAt timestamp
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
// NewTagRecord creates a new tag record
|
|
func NewTagRecord(repository, tag, manifestDigest string) *TagRecord {
|
|
return &TagRecord{
|
|
Type: TagCollection,
|
|
Repository: repository,
|
|
Tag: tag,
|
|
ManifestDigest: manifestDigest,
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// HoldRecord represents a storage hold definition (BYOS)
|
|
// Users create these records to define where their blobs should be stored
|
|
type HoldRecord struct {
|
|
// Type should be "io.atcr.hold"
|
|
Type string `json:"$type"`
|
|
|
|
// Endpoint is the URL of the hold service
|
|
// e.g., "https://hold1.example.com"
|
|
Endpoint string `json:"endpoint"`
|
|
|
|
// Owner is the DID of the hold owner
|
|
Owner string `json:"owner"`
|
|
|
|
// Public controls whether this hold allows public blob reads (pulls) without auth
|
|
// Writes always require crew membership
|
|
Public bool `json:"public"`
|
|
|
|
// CreatedAt timestamp
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// NewHoldRecord creates a new hold record
|
|
func NewHoldRecord(endpoint, owner string, public bool) *HoldRecord {
|
|
return &HoldRecord{
|
|
Type: HoldCollection,
|
|
Endpoint: endpoint,
|
|
Owner: owner,
|
|
Public: public,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// HoldCrewRecord represents membership in a storage hold
|
|
// Stored in the hold owner's PDS (not the crew member's PDS) to ensure owner maintains full control
|
|
// Owner can add/remove crew members by creating/deleting these records in their own PDS
|
|
type HoldCrewRecord struct {
|
|
// Type should be "io.atcr.hold.crew"
|
|
Type string `json:"$type"`
|
|
|
|
// Hold is the AT URI of the hold record
|
|
// e.g., "at://did:plc:owner/io.atcr.hold/hold1"
|
|
Hold string `json:"hold"`
|
|
|
|
// Member is the DID of the crew member
|
|
Member string `json:"member"`
|
|
|
|
// Role defines permissions: "owner", "write", "read"
|
|
Role string `json:"role"`
|
|
|
|
// AddedAt timestamp
|
|
AddedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// NewHoldCrewRecord creates a new hold crew record
|
|
func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord {
|
|
return &HoldCrewRecord{
|
|
Type: HoldCrewCollection,
|
|
Hold: hold,
|
|
Member: member,
|
|
Role: role,
|
|
AddedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// SailorProfileRecord represents a user's profile with registry preferences
|
|
// Stored in the user's PDS to configure default hold and other settings
|
|
type SailorProfileRecord struct {
|
|
// Type should be "io.atcr.sailor.profile"
|
|
Type string `json:"$type"`
|
|
|
|
// DefaultHold is the default hold endpoint for blob storage
|
|
// If null/empty, user has opted out of defaults
|
|
DefaultHold string `json:"defaultHold,omitempty"`
|
|
|
|
// CreatedAt timestamp
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
// UpdatedAt timestamp
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
// NewSailorProfileRecord creates a new sailor profile record
|
|
func NewSailorProfileRecord(defaultHold string) *SailorProfileRecord {
|
|
now := time.Now()
|
|
return &SailorProfileRecord{
|
|
Type: SailorProfileCollection,
|
|
DefaultHold: defaultHold,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
// StarSubject represents the subject of a star (the repository being starred)
|
|
type StarSubject struct {
|
|
// DID is the DID of the repository owner
|
|
DID string `json:"did"`
|
|
|
|
// Repository is the name of the repository
|
|
Repository string `json:"repository"`
|
|
}
|
|
|
|
// StarRecord represents a user starring a repository
|
|
// Stored in the starrer's PDS (like Bluesky likes)
|
|
type StarRecord struct {
|
|
// Type should be "io.atcr.sailor.star"
|
|
Type string `json:"$type"`
|
|
|
|
// Subject is the repository being starred
|
|
Subject StarSubject `json:"subject"`
|
|
|
|
// CreatedAt timestamp
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// NewStarRecord creates a new star record
|
|
func NewStarRecord(ownerDID, repository string) *StarRecord {
|
|
return &StarRecord{
|
|
Type: StarCollection,
|
|
Subject: StarSubject{
|
|
DID: ownerDID,
|
|
Repository: repository,
|
|
},
|
|
CreatedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// StarRecordKey generates a record key for a star
|
|
// Uses a simple hash to ensure uniqueness and prevent duplicate stars
|
|
func StarRecordKey(ownerDID, repository string) string {
|
|
// Use base64 encoding of "ownerDID/repository" as the record key
|
|
// This is deterministic and prevents duplicate stars
|
|
combined := ownerDID + "/" + repository
|
|
return base64.RawURLEncoding.EncodeToString([]byte(combined))
|
|
}
|
|
|
|
// ParseStarRecordKey decodes a star record key back to ownerDID and repository
|
|
func ParseStarRecordKey(rkey string) (ownerDID, repository string, err error) {
|
|
decoded, err := base64.RawURLEncoding.DecodeString(rkey)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to decode star rkey: %w", err)
|
|
}
|
|
|
|
parts := strings.SplitN(string(decoded), "/", 2)
|
|
if len(parts) != 2 {
|
|
return "", "", fmt.Errorf("invalid star rkey format: %s", string(decoded))
|
|
}
|
|
|
|
return parts[0], parts[1], nil
|
|
}
|