Skip to content

Commit af3dfb0

Browse files
authored
feat(registry): Add OCI Distribution registry for docker push support (#19)
* 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. * fix: convert Docker v2 manifests to OCI format when pushing images Images pushed from the local Docker daemon use Docker v2 manifest format, but umoci (used for unpacking) only handles OCI format. This caused 'manifest data is not v1.Manifest' errors during image conversion. Changes: - Add blobStoreImage wrapper that transparently converts Docker v2 to OCI - Convert manifest, config, and layer media types to OCI equivalents - Compute correct digest for converted OCI manifests - Add TestRegistryDockerV2ManifestConversion to verify the fix - Update existing tests to verify images appear in ListImages after push - Update README to clarify docker push limitations * go.sum * readme updates * verify /dev/kvm access on startup * remove old-style (pre go 1.17) build constraint syntax * clean up test * final cleanup
1 parent c4f95e1 commit af3dfb0

File tree

12 files changed

+1525
-4
lines changed

12 files changed

+1525
-4
lines changed

cmd/api/api/registry_test.go

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

cmd/api/api/volumes_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func TestGetVolume_ByName(t *testing.T) {
3737

3838
// Create a volume
3939
createResp, err := svc.CreateVolume(ctx(), oapi.CreateVolumeRequestObject{
40-
Body: &oapi.CreateVolumeRequest{
40+
JSONBody: &oapi.CreateVolumeRequest{
4141
Name: "my-data",
4242
SizeGb: 1,
4343
},
@@ -62,7 +62,7 @@ func TestDeleteVolume_ByName(t *testing.T) {
6262

6363
// Create a volume
6464
_, err := svc.CreateVolume(ctx(), oapi.CreateVolumeRequestObject{
65-
Body: &oapi.CreateVolumeRequest{
65+
JSONBody: &oapi.CreateVolumeRequest{
6666
Name: "to-delete",
6767
SizeGb: 1,
6868
},

cmd/api/main.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ func run() error {
108108
logger.Warn("JWT_SECRET not configured - API authentication will fail")
109109
}
110110

111+
// Verify KVM access (required for VM creation)
112+
if err := checkKVMAccess(); err != nil {
113+
return fmt.Errorf("KVM access check failed: %w\n\nEnsure:\n 1. KVM is enabled (check /dev/kvm exists)\n 2. User is in 'kvm' group: sudo usermod -aG kvm $USER\n 3. Log out and back in, or use: newgrp kvm", err)
114+
}
115+
logger.Info("KVM access verified")
116+
111117
// Validate log rotation config
112118
var logMaxSize datasize.ByteSize
113119
if err := logMaxSize.UnmarshalText([]byte(app.Config.LogMaxSize)); err != nil {
@@ -178,6 +184,16 @@ func run() error {
178184
mw.JwtAuth(app.Config.JwtSecret),
179185
).Get("/instances/{id}/exec", app.ApiService.ExecHandler)
180186

187+
// OCI Distribution registry endpoints for image push (outside OpenAPI spec)
188+
r.Route("/v2", func(r chi.Router) {
189+
r.Use(middleware.RequestID)
190+
r.Use(middleware.RealIP)
191+
r.Use(middleware.Logger)
192+
r.Use(middleware.Recoverer)
193+
r.Use(mw.JwtAuth(app.Config.JwtSecret))
194+
r.Mount("/", app.Registry.Handler())
195+
})
196+
181197
// Authenticated API endpoints
182198
r.Group(func(r chi.Router) {
183199
// Common middleware
@@ -323,3 +339,19 @@ func getRunningInstanceIDs(app *application) []string {
323339
}
324340
return running
325341
}
342+
343+
// checkKVMAccess verifies KVM is available and the user has permission to use it
344+
func checkKVMAccess() error {
345+
f, err := os.OpenFile("/dev/kvm", os.O_RDWR, 0)
346+
if err != nil {
347+
if os.IsNotExist(err) {
348+
return fmt.Errorf("/dev/kvm not found - KVM not enabled or not supported")
349+
}
350+
if os.IsPermission(err) {
351+
return fmt.Errorf("permission denied accessing /dev/kvm - user not in 'kvm' group")
352+
}
353+
return fmt.Errorf("cannot access /dev/kvm: %w", err)
354+
}
355+
f.Close()
356+
return nil
357+
}

cmd/api/wire.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// +build wireinject
1+
//go:build wireinject
22

33
package main
44

@@ -13,6 +13,7 @@ import (
1313
"github.com/onkernel/hypeman/lib/instances"
1414
"github.com/onkernel/hypeman/lib/network"
1515
"github.com/onkernel/hypeman/lib/providers"
16+
"github.com/onkernel/hypeman/lib/registry"
1617
"github.com/onkernel/hypeman/lib/system"
1718
"github.com/onkernel/hypeman/lib/volumes"
1819
)
@@ -27,6 +28,7 @@ type application struct {
2728
NetworkManager network.Manager
2829
InstanceManager instances.Manager
2930
VolumeManager volumes.Manager
31+
Registry *registry.Registry
3032
ApiService *api.ApiService
3133
}
3234

@@ -42,8 +44,8 @@ func initializeApp() (*application, func(), error) {
4244
providers.ProvideNetworkManager,
4345
providers.ProvideInstanceManager,
4446
providers.ProvideVolumeManager,
47+
providers.ProvideRegistry,
4548
api.New,
4649
wire.Struct(new(application), "*"),
4750
))
4851
}
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
@@ -71,6 +71,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
7171
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
7272
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
7373
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
74+
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
75+
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
7476
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7577
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
7678
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -232,6 +234,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
232234
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
233235
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
234236
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
237+
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
238+
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
235239
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
236240
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
237241
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -256,6 +260,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
256260
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
257261
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
258262
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
263+
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
264+
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
259265
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
260266
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
261267
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=

lib/images/manager.go

Lines changed: 44 additions & 0 deletions
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

@@ -24,6 +25,9 @@ const (
2425
type Manager interface {
2526
ListImages(ctx context.Context) ([]Image, error)
2627
CreateImage(ctx context.Context, req CreateImageRequest) (*Image, error)
28+
// ImportLocalImage imports an image that was pushed to the local OCI cache.
29+
// Unlike CreateImage, it does not resolve from a remote registry.
30+
ImportLocalImage(ctx context.Context, repo, reference, digest string) (*Image, error)
2731
GetImage(ctx context.Context, name string) (*Image, error)
2832
DeleteImage(ctx context.Context, name string) error
2933
RecoverInterruptedBuilds()
@@ -120,6 +124,46 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima
120124
return m.createAndQueueImage(ref)
121125
}
122126

127+
// ImportLocalImage imports an image from the local OCI cache without resolving from a remote registry.
128+
// This is used for images that were pushed directly to the hypeman registry.
129+
func (m *manager) ImportLocalImage(ctx context.Context, repo, reference, digest string) (*Image, error) {
130+
// Build the image reference string
131+
var imageRef string
132+
if strings.HasPrefix(reference, "sha256:") {
133+
imageRef = repo + "@" + reference
134+
} else {
135+
imageRef = repo + ":" + reference
136+
}
137+
138+
// Parse and normalize
139+
normalized, err := ParseNormalizedRef(imageRef)
140+
if err != nil {
141+
return nil, fmt.Errorf("%w: %s", ErrInvalidName, err.Error())
142+
}
143+
144+
// Create a ResolvedRef directly with the provided digest
145+
ref := NewResolvedRef(normalized, digest)
146+
147+
m.createMu.Lock()
148+
defer m.createMu.Unlock()
149+
150+
// Check if we already have this digest (deduplication)
151+
if meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()); err == nil {
152+
// We have this digest already
153+
if meta.Status == StatusReady && ref.Tag() != "" {
154+
createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex())
155+
}
156+
img := meta.toImage()
157+
if meta.Status == StatusPending {
158+
img.QueuePosition = m.queue.GetPosition(meta.Digest)
159+
}
160+
return img, nil
161+
}
162+
163+
// Don't have this digest yet, queue the build
164+
return m.createAndQueueImage(ref)
165+
}
166+
123167
func (m *manager) createAndQueueImage(ref *ResolvedRef) (*Image, error) {
124168
meta := &imageMetadata{
125169
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}/
@@ -78,6 +81,26 @@ func (p *Paths) SystemOCICache() string {
7881
return filepath.Join(p.dataDir, "system", "oci-cache")
7982
}
8083

84+
// OCICacheBlobDir returns the path to the OCI cache blobs directory.
85+
func (p *Paths) OCICacheBlobDir() string {
86+
return filepath.Join(p.SystemOCICache(), "blobs", "sha256")
87+
}
88+
89+
// OCICacheBlob returns the path to a specific blob in the OCI cache.
90+
func (p *Paths) OCICacheBlob(digestHex string) string {
91+
return filepath.Join(p.OCICacheBlobDir(), digestHex)
92+
}
93+
94+
// OCICacheIndex returns the path to the OCI cache index.json.
95+
func (p *Paths) OCICacheIndex() string {
96+
return filepath.Join(p.SystemOCICache(), "index.json")
97+
}
98+
99+
// OCICacheLayout returns the path to the OCI cache oci-layout file.
100+
func (p *Paths) OCICacheLayout() string {
101+
return filepath.Join(p.SystemOCICache(), "oci-layout")
102+
}
103+
81104
// SystemBuild returns the path to a system build directory.
82105
func (p *Paths) SystemBuild(ref string) string {
83106
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/network"
1414
hypemanotel "github.com/onkernel/hypeman/lib/otel"
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
"go.opentelemetry.io/otel"
@@ -113,3 +114,8 @@ func ProvideVolumeManager(p *paths.Paths, cfg *config.Config) (volumes.Manager,
113114
meter := otel.GetMeterProvider().Meter("hypeman")
114115
return volumes.NewManager(p, maxTotalVolumeStorage, meter), nil
115116
}
117+
118+
// ProvideRegistry provides the OCI registry for image push
119+
func ProvideRegistry(p *paths.Paths, imageManager images.Manager) (*registry.Registry, error) {
120+
return registry.New(p, imageManager)
121+
}

0 commit comments

Comments
 (0)