Skip to content

Commit 8cf61a6

Browse files
committed
feat(registry): add OCI Distribution registry for image push
Implement OCI Distribution Spec compliant registry endpoints that accept pushed images and trigger conversion to hypeman's disk format. Changes: - Add lib/registry with BlobStore and Registry handlers - Mount /v2 endpoints in API with JWT auth - Add ImportLocalImage to image manager for local pushes - Add OCI cache path helpers to lib/paths - Add comprehensive tests for push, caching, and layer deduplication The registry uses go-containerregistry for protocol handling with custom blob storage. Layer caching is automatic - subsequent pushes skip already stored blobs. Manifest pushes trigger async conversion to ext4 disk format.
1 parent 2df5dd5 commit 8cf61a6

File tree

11 files changed

+1063
-3
lines changed

11 files changed

+1063
-3
lines changed

cmd/api/api/registry_test.go

Lines changed: 422 additions & 0 deletions
Large diffs are not rendered by default.

cmd/api/main.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ func run() error {
103103
mw.JwtAuth(app.Config.JwtSecret),
104104
).Get("/instances/{id}/exec", app.ApiService.ExecHandler)
105105

106+
// OCI Distribution registry endpoints for image push (outside OpenAPI spec)
107+
r.Route("/v2", func(r chi.Router) {
108+
r.Use(middleware.RequestID)
109+
r.Use(middleware.RealIP)
110+
r.Use(middleware.Logger)
111+
r.Use(middleware.Recoverer)
112+
r.Use(mw.JwtAuth(app.Config.JwtSecret))
113+
r.Mount("/", app.Registry.Handler())
114+
})
115+
106116
// Authenticated API endpoints
107117
r.Group(func(r chi.Router) {
108118
// Common middleware
@@ -225,4 +235,3 @@ func getRunningInstanceIDs(app *application) []string {
225235
}
226236
return running
227237
}
228-

cmd/api/wire.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
//go:build wireinject
12
// +build wireinject
23

34
package main
@@ -13,6 +14,7 @@ import (
1314
"github.com/onkernel/hypeman/lib/instances"
1415
"github.com/onkernel/hypeman/lib/network"
1516
"github.com/onkernel/hypeman/lib/providers"
17+
"github.com/onkernel/hypeman/lib/registry"
1618
"github.com/onkernel/hypeman/lib/system"
1719
"github.com/onkernel/hypeman/lib/volumes"
1820
)
@@ -27,6 +29,7 @@ type application struct {
2729
NetworkManager network.Manager
2830
InstanceManager instances.Manager
2931
VolumeManager volumes.Manager
32+
Registry *registry.Registry
3033
ApiService *api.ApiService
3134
}
3235

@@ -42,8 +45,8 @@ func initializeApp() (*application, func(), error) {
4245
providers.ProvideNetworkManager,
4346
providers.ProvideInstanceManager,
4447
providers.ProvideVolumeManager,
48+
providers.ProvideRegistry,
4549
api.New,
4650
wire.Struct(new(application), "*"),
4751
))
4852
}
49-

cmd/api/wire_gen.go

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
6666
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
6767
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
6868
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
69+
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
70+
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
6971
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7072
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
7173
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -203,6 +205,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
203205
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
204206
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
205207
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
208+
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
209+
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
206210
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
207211
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
208212
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -227,6 +231,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
227231
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
228232
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
229233
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
234+
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
235+
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
230236
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
231237
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
232238
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=

lib/images/manager.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88
"sort"
9+
"strings"
910
"sync"
1011
"time"
1112

@@ -23,6 +24,9 @@ const (
2324
type Manager interface {
2425
ListImages(ctx context.Context) ([]Image, error)
2526
CreateImage(ctx context.Context, req CreateImageRequest) (*Image, error)
27+
// ImportLocalImage imports an image that was pushed to the local OCI cache.
28+
// Unlike CreateImage, it does not resolve from a remote registry.
29+
ImportLocalImage(ctx context.Context, repo, reference, digest string) (*Image, error)
2630
GetImage(ctx context.Context, name string) (*Image, error)
2731
DeleteImage(ctx context.Context, name string) error
2832
RecoverInterruptedBuilds()
@@ -78,7 +82,7 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima
7882
// Add a 2-second timeout to ensure fast failure on rate limits or errors
7983
resolveCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
8084
defer cancel()
81-
85+
8286
ref, err := normalized.Resolve(resolveCtx, m.ociClient)
8387
if err != nil {
8488
return nil, fmt.Errorf("resolve manifest: %w", err)
@@ -107,6 +111,46 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima
107111
return m.createAndQueueImage(ref)
108112
}
109113

114+
// ImportLocalImage imports an image from the local OCI cache without resolving from a remote registry.
115+
// This is used for images that were pushed directly to the hypeman registry.
116+
func (m *manager) ImportLocalImage(ctx context.Context, repo, reference, digest string) (*Image, error) {
117+
// Build the image reference string
118+
var imageRef string
119+
if strings.HasPrefix(reference, "sha256:") {
120+
imageRef = repo + "@" + reference
121+
} else {
122+
imageRef = repo + ":" + reference
123+
}
124+
125+
// Parse and normalize
126+
normalized, err := ParseNormalizedRef(imageRef)
127+
if err != nil {
128+
return nil, fmt.Errorf("%w: %s", ErrInvalidName, err.Error())
129+
}
130+
131+
// Create a ResolvedRef directly with the provided digest
132+
ref := NewResolvedRef(normalized, digest)
133+
134+
m.createMu.Lock()
135+
defer m.createMu.Unlock()
136+
137+
// Check if we already have this digest (deduplication)
138+
if meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()); err == nil {
139+
// We have this digest already
140+
if meta.Status == StatusReady && ref.Tag() != "" {
141+
createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex())
142+
}
143+
img := meta.toImage()
144+
if meta.Status == StatusPending {
145+
img.QueuePosition = m.queue.GetPosition(meta.Digest)
146+
}
147+
return img, nil
148+
}
149+
150+
// Don't have this digest yet, queue the build
151+
return m.createAndQueueImage(ref)
152+
}
153+
110154
func (m *manager) createAndQueueImage(ref *ResolvedRef) (*Image, error) {
111155
meta := &imageMetadata{
112156
Name: ref.String(),

lib/paths/paths.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
// initrd/{arch}/latest -> {timestamp}
1010
// binaries/{version}/{arch}/cloud-hypervisor
1111
// oci-cache/
12+
// oci-layout
13+
// index.json
14+
// blobs/sha256/{digestHex}
1215
// builds/{ref}/
1316
// images/
1417
// {repository}/{digest}/
@@ -72,6 +75,26 @@ func (p *Paths) SystemOCICache() string {
7275
return filepath.Join(p.dataDir, "system", "oci-cache")
7376
}
7477

78+
// OCICacheBlobDir returns the path to the OCI cache blobs directory.
79+
func (p *Paths) OCICacheBlobDir() string {
80+
return filepath.Join(p.SystemOCICache(), "blobs", "sha256")
81+
}
82+
83+
// OCICacheBlob returns the path to a specific blob in the OCI cache.
84+
func (p *Paths) OCICacheBlob(digestHex string) string {
85+
return filepath.Join(p.OCICacheBlobDir(), digestHex)
86+
}
87+
88+
// OCICacheIndex returns the path to the OCI cache index.json.
89+
func (p *Paths) OCICacheIndex() string {
90+
return filepath.Join(p.SystemOCICache(), "index.json")
91+
}
92+
93+
// OCICacheLayout returns the path to the OCI cache oci-layout file.
94+
func (p *Paths) OCICacheLayout() string {
95+
return filepath.Join(p.SystemOCICache(), "oci-layout")
96+
}
97+
7598
// SystemBuild returns the path to a system build directory.
7699
func (p *Paths) SystemBuild(ref string) string {
77100
return filepath.Join(p.dataDir, "system", "builds", ref)

lib/providers/providers.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/onkernel/hypeman/lib/logger"
1414
"github.com/onkernel/hypeman/lib/network"
1515
"github.com/onkernel/hypeman/lib/paths"
16+
"github.com/onkernel/hypeman/lib/registry"
1617
"github.com/onkernel/hypeman/lib/system"
1718
"github.com/onkernel/hypeman/lib/volumes"
1819
)
@@ -68,3 +69,8 @@ func ProvideInstanceManager(p *paths.Paths, cfg *config.Config, imageManager ima
6869
func ProvideVolumeManager(p *paths.Paths) volumes.Manager {
6970
return volumes.NewManager(p)
7071
}
72+
73+
// ProvideRegistry provides the OCI registry for image push
74+
func ProvideRegistry(p *paths.Paths, imageManager images.Manager) (*registry.Registry, error) {
75+
return registry.New(p, imageManager)
76+
}

lib/registry/README.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# OCI Distribution Registry
2+
3+
Implements an OCI Distribution Spec compliant registry that accepts pushed images and triggers conversion to hypeman's disk format.
4+
5+
## Architecture
6+
7+
```mermaid
8+
sequenceDiagram
9+
participant Client as Docker Client
10+
participant Registry as Hypeman Registry
11+
participant BlobStore as Blob Store
12+
participant ImageMgr as Image Manager
13+
14+
Client->>Registry: PUT /v2/.../blobs/{digest}
15+
Registry->>BlobStore: Store blob
16+
BlobStore-->>Registry: OK
17+
Registry-->>Client: 201 Created
18+
19+
Client->>Registry: PUT /v2/.../manifests/{ref}
20+
Registry->>BlobStore: Store manifest blob
21+
Registry->>Registry: Update index.json
22+
Registry-->>Client: 201 Created
23+
24+
Registry--)ImageMgr: ImportLocalImage() (async)
25+
ImageMgr->>ImageMgr: Queue conversion
26+
ImageMgr->>ImageMgr: Unpack layers
27+
ImageMgr->>ImageMgr: Create disk image
28+
```
29+
30+
## How It Works
31+
32+
### Push Flow
33+
34+
1. **Version Check**: Client hits `GET /v2/` to verify registry compatibility
35+
2. **Blob Check**: Client does `HEAD /v2/{name}/blobs/{digest}` to check if layers exist
36+
3. **Blob Upload**: Missing blobs uploaded via `POST/PATCH/PUT` sequence
37+
4. **Manifest Upload**: Final `PUT /v2/{name}/manifests/{reference}` triggers conversion
38+
39+
### Layer Caching
40+
41+
Blobs are stored content-addressably in `system/oci-cache/blobs/sha256/`:
42+
43+
```go
44+
// BlobStore.Stat() - Returns size if exists, ErrNotFound otherwise
45+
func (s *BlobStore) Stat(ctx context.Context, repo string, h v1.Hash) (int64, error) {
46+
path := s.blobPath(h.String())
47+
info, err := os.Stat(path)
48+
if os.IsNotExist(err) {
49+
return 0, ErrNotFound // Client will upload
50+
}
51+
return info.Size(), nil // Client skips upload
52+
}
53+
```
54+
55+
When a client pushes:
56+
- First push: HEAD returns 404 → uploads all blobs
57+
- Second push: HEAD returns 200 with size → skips upload entirely
58+
59+
### Manifest Handling
60+
61+
go-containerregistry stores manifests in-memory, but we need them on disk for conversion. The registry intercepts manifest PUTs:
62+
63+
```go
64+
// Read manifest body before passing to underlying handler
65+
body, _ := io.ReadAll(req.Body)
66+
67+
// Store in blob store (for image manager to read later)
68+
r.storeManifestBlob(reference, body)
69+
70+
// Reconstruct body for underlying handler
71+
req.Body = io.NopCloser(bytes.NewReader(body))
72+
r.handler.ServeHTTP(wrapper, req)
73+
74+
// Trigger async conversion
75+
if wrapper.statusCode == http.StatusCreated {
76+
go r.triggerConversion(repo, reference)
77+
}
78+
```
79+
80+
### Conversion Trigger
81+
82+
After a successful manifest push:
83+
84+
1. Updates `index.json` with manifest entry (correct mediaType detected from content)
85+
2. Calls `ImageManager.ImportLocalImage()` to queue conversion
86+
3. Image manager reads from shared OCI cache and creates disk image
87+
88+
## Files
89+
90+
- **`blob_store.go`** - Filesystem-backed blob storage implementing `registry.BlobHandler`
91+
- **`registry.go`** - Registry handler wrapping go-containerregistry with manifest interception
92+
93+
## Storage Layout
94+
95+
```
96+
/var/lib/hypeman/system/oci-cache/
97+
oci-layout # {"imageLayoutVersion": "1.0.0"}
98+
index.json # Manifest index with annotations
99+
blobs/sha256/
100+
2d35eb... # Layer blob (shared across all images)
101+
706db5... # Config blob
102+
85f2b7... # Manifest blob
103+
```
104+
105+
## CLI Usage
106+
107+
```bash
108+
# Push from local Docker daemon
109+
hypeman push myimage:latest
110+
111+
# Push with custom target name
112+
hypeman push myimage:latest my-custom-name
113+
```
114+
115+
## Authentication
116+
117+
The registry endpoints use the same JWT authentication as the rest of the API. The CLI reads `HYPEMAN_API_KEY` or `HYPEMAN_BEARER_TOKEN` and passes it as a registry token.
118+
119+
## Limitations
120+
121+
- **Docker v2 manifests**: Images from local Docker daemon use Docker v2 manifest format which may need additional handling for conversion. Images pulled directly from registries (OCI format) work seamlessly.
122+
- **Digest references only**: Conversion is triggered only for digest references (`@sha256:...`), not tag references (`:latest`). The CLI automatically converts to digest references.
123+
124+
## Design Decisions
125+
126+
### Why wrap go-containerregistry/pkg/registry?
127+
128+
**What:** Use the existing registry implementation from go-containerregistry with custom blob storage.
129+
130+
**Why:**
131+
- Battle-tested OCI Distribution Spec compliance
132+
- Handles chunked uploads, content negotiation, error responses
133+
- We only need to customize storage, not protocol handling
134+
135+
### Why store manifests separately?
136+
137+
**What:** Intercept manifest PUT and store in blob store.
138+
139+
**Why:**
140+
- go-containerregistry stores manifests in-memory by default
141+
- Our image manager needs to read manifests from disk
142+
- Enables content-addressable manifest storage consistent with layers
143+
144+
### Why detect mediaType from manifest content?
145+
146+
**What:** Parse manifest JSON to extract actual mediaType rather than hardcoding.
147+
148+
**Why:**
149+
- Docker v2 (`application/vnd.docker.distribution.manifest.v2+json`) vs OCI (`application/vnd.oci.image.manifest.v1+json`)
150+
- Wrong mediaType in index.json causes "malicious manifest" errors during conversion
151+
- Content-based detection ensures compatibility with all sources

0 commit comments

Comments
 (0)