diff --git a/.air.toml b/.air.toml index d30c8963..05d74ce2 100644 --- a/.air.toml +++ b/.air.toml @@ -5,7 +5,7 @@ tmp_dir = "tmp" [build] args_bin = [] bin = "./tmp/main" - cmd = "go build -o ./tmp/main ./cmd/api" + cmd = "go build -tags containers_image_openpgp -o ./tmp/main ./cmd/api" delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "testdata", "bin", "scripts", "data", "kernel"] exclude_file = [] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36e6b71d..e828044d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,18 @@ jobs: go-version: '1.25' - name: Install dependencies - run: go mod download + run: | + set -xe + sudo apt-get install -y erofs-utils + go mod download + + # Avoids rate limits when running the tests + # Tests includes pulling, then converting to disk images + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Run tests run: make test diff --git a/Makefile b/Makefile index 94a3204c..0412ccc5 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ generate-all: oapi-generate generate-wire # Build the binary build: | $(BIN_DIR) - go build -o $(BIN_DIR)/hypeman ./cmd/api + go build -tags containers_image_openpgp -o $(BIN_DIR)/hypeman ./cmd/api # Run in development mode with hot reload dev: $(AIR) @@ -51,7 +51,7 @@ dev: $(AIR) # Run tests test: - go test -v -timeout 30s ./... + go test -tags containers_image_openpgp -v -timeout 30s ./... # Clean generated files and binaries clean: diff --git a/README.md b/README.md index 419c68a9..771d2d58 100644 --- a/README.md +++ b/README.md @@ -8,26 +8,40 @@ Run containerized workloads in VMs, powered by [Cloud Hypervisor](https://github ### Prerequisites -**Cloud Hypervisor** - [Installation guide](https://www.cloudhypervisor.org/docs/prologue/quick-start/#use-pre-built-binaries) -```bash -cloud-hypervisor --version # Verify -ch-remote --version -``` +**Go 1.25.4+**, **Cloud Hypervisor**, **KVM**, **erofs-utils** -**containerd** - [Installation guide](https://github.com/containerd/containerd/blob/main/docs/getting-started.md) ```bash -containerd --version # Verify +cloud-hypervisor --version +mkfs.erofs --version ``` -**Go 1.25.4+** and **KVM** - ### Configuration +#### Environment variables + ```bash cp .env.example .env # Edit .env and set JWT_SECRET ``` +#### Data directory + +Hypeman stores data in a configurable directory. Configure permissions for this directory. + +```bash +sudo mkdir /var/lib/hypeman +sudo chown $USER:$USER /var/lib/hypeman +``` + +#### Dockerhub login + +Requires Docker Hub authentication to avoid rate limits when running the tests: +```bash +docker login +``` + +Docker itself isn't required to be installed. `~/.docker/config.json` is a standard used for handling registry authentication. + ### Build ```bash diff --git a/cmd/api/api/api_test.go b/cmd/api/api/api_test.go index 305e6430..5afad7ed 100644 --- a/cmd/api/api/api_test.go +++ b/cmd/api/api/api_test.go @@ -16,9 +16,14 @@ func newTestService(t *testing.T) *ApiService { DataDir: t.TempDir(), } + imageMgr, err := images.NewManager(cfg.DataDir, 1) + if err != nil { + t.Fatalf("failed to create image manager: %v", err) + } + return &ApiService{ Config: cfg, - ImageManager: images.NewManager(cfg.DataDir), + ImageManager: imageMgr, InstanceManager: instances.NewManager(cfg.DataDir), VolumeManager: volumes.NewManager(cfg.DataDir), } @@ -27,4 +32,3 @@ func newTestService(t *testing.T) *ApiService { func ctx() context.Context { return context.Background() } - diff --git a/cmd/api/api/images.go b/cmd/api/api/images.go index 470cee31..b1cd4b60 100644 --- a/cmd/api/api/images.go +++ b/cmd/api/api/images.go @@ -9,11 +9,10 @@ import ( "github.com/onkernel/hypeman/lib/oapi" ) -// ListImages lists all images func (s *ApiService) ListImages(ctx context.Context, request oapi.ListImagesRequestObject) (oapi.ListImagesResponseObject, error) { log := logger.FromContext(ctx) - imgs, err := s.ImageManager.ListImages(ctx) + domainImages, err := s.ImageManager.ListImages(ctx) if err != nil { log.Error("failed to list images", "error", err) return oapi.ListImages500JSONResponse{ @@ -21,20 +20,34 @@ func (s *ApiService) ListImages(ctx context.Context, request oapi.ListImagesRequ Message: "failed to list images", }, nil } - return oapi.ListImages200JSONResponse(imgs), nil + + oapiImages := make([]oapi.Image, len(domainImages)) + for i, img := range domainImages { + oapiImages[i] = imageToOAPI(img) + } + + return oapi.ListImages200JSONResponse(oapiImages), nil } -// CreateImage creates a new image from an OCI reference func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRequestObject) (oapi.CreateImageResponseObject, error) { log := logger.FromContext(ctx) - img, err := s.ImageManager.CreateImage(ctx, *request.Body) + domainReq := images.CreateImageRequest{ + Name: request.Body.Name, + } + + img, err := s.ImageManager.CreateImage(ctx, domainReq) if err != nil { switch { - case errors.Is(err, images.ErrAlreadyExists): + case errors.Is(err, images.ErrInvalidName): return oapi.CreateImage400JSONResponse{ - Code: "already_exists", - Message: "image already exists", + Code: "invalid_name", + Message: err.Error(), + }, nil + case errors.Is(err, images.ErrNotFound): + return oapi.CreateImage404JSONResponse{ + Code: "not_found", + Message: "image not found", }, nil default: log.Error("failed to create image", "error", err, "name", request.Body.Name) @@ -44,46 +57,44 @@ func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRe }, nil } } - return oapi.CreateImage201JSONResponse(*img), nil + return oapi.CreateImage202JSONResponse(imageToOAPI(*img)), nil } -// GetImage gets image details func (s *ApiService) GetImage(ctx context.Context, request oapi.GetImageRequestObject) (oapi.GetImageResponseObject, error) { log := logger.FromContext(ctx) - img, err := s.ImageManager.GetImage(ctx, request.Id) + img, err := s.ImageManager.GetImage(ctx, request.Name) if err != nil { switch { - case errors.Is(err, images.ErrNotFound): + case errors.Is(err, images.ErrInvalidName), errors.Is(err, images.ErrNotFound): return oapi.GetImage404JSONResponse{ Code: "not_found", Message: "image not found", }, nil default: - log.Error("failed to get image", "error", err, "id", request.Id) + log.Error("failed to get image", "error", err, "name", request.Name) return oapi.GetImage500JSONResponse{ Code: "internal_error", Message: "failed to get image", }, nil } } - return oapi.GetImage200JSONResponse(*img), nil + return oapi.GetImage200JSONResponse(imageToOAPI(*img)), nil } -// DeleteImage deletes an image func (s *ApiService) DeleteImage(ctx context.Context, request oapi.DeleteImageRequestObject) (oapi.DeleteImageResponseObject, error) { log := logger.FromContext(ctx) - err := s.ImageManager.DeleteImage(ctx, request.Id) + err := s.ImageManager.DeleteImage(ctx, request.Name) if err != nil { switch { - case errors.Is(err, images.ErrNotFound): + case errors.Is(err, images.ErrInvalidName), errors.Is(err, images.ErrNotFound): return oapi.DeleteImage404JSONResponse{ Code: "not_found", Message: "image not found", }, nil default: - log.Error("failed to delete image", "error", err, "id", request.Id) + log.Error("failed to delete image", "error", err, "name", request.Name) return oapi.DeleteImage500JSONResponse{ Code: "internal_error", Message: "failed to delete image", @@ -93,3 +104,29 @@ func (s *ApiService) DeleteImage(ctx context.Context, request oapi.DeleteImageRe return oapi.DeleteImage204Response{}, nil } +func imageToOAPI(img images.Image) oapi.Image { + oapiImg := oapi.Image{ + Name: img.Name, + Digest: img.Digest, + Status: oapi.ImageStatus(img.Status), + QueuePosition: img.QueuePosition, + Error: img.Error, + SizeBytes: img.SizeBytes, + CreatedAt: img.CreatedAt, + } + + if len(img.Entrypoint) > 0 { + oapiImg.Entrypoint = &img.Entrypoint + } + if len(img.Cmd) > 0 { + oapiImg.Cmd = &img.Cmd + } + if len(img.Env) > 0 { + oapiImg.Env = &img.Env + } + if img.WorkingDir != "" { + oapiImg.WorkingDir = &img.WorkingDir + } + + return oapiImg +} diff --git a/cmd/api/api/images_test.go b/cmd/api/api/images_test.go index d948af9c..168578d3 100644 --- a/cmd/api/api/images_test.go +++ b/cmd/api/api/images_test.go @@ -1,8 +1,12 @@ package api import ( + "fmt" + "strings" "testing" + "time" + "github.com/onkernel/hypeman/lib/images" "github.com/onkernel/hypeman/lib/oapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -23,7 +27,7 @@ func TestGetImage_NotFound(t *testing.T) { svc := newTestService(t) resp, err := svc.GetImage(ctx(), oapi.GetImageRequestObject{ - Id: "non-existent", + Name: "non-existent:latest", }) require.NoError(t, err) @@ -33,3 +37,269 @@ func TestGetImage_NotFound(t *testing.T) { assert.Equal(t, "image not found", notFound.Message) } +func TestCreateImage_Async(t *testing.T) { + svc := newTestService(t) + ctx := ctx() + + // Create images before alpine to populate the queue + t.Log("Creating image queue...") + queueImages := []string{ + "docker.io/library/busybox:latest", + "docker.io/library/nginx:alpine", + } + for _, name := range queueImages { + _, err := svc.CreateImage(ctx, oapi.CreateImageRequestObject{ + Body: &oapi.CreateImageRequest{Name: name}, + }) + require.NoError(t, err) + } + + // Create alpine (should be last in queue) + t.Log("Creating alpine image (should be queued)...") + createResp, err := svc.CreateImage(ctx, oapi.CreateImageRequestObject{ + Body: &oapi.CreateImageRequest{ + Name: "docker.io/library/alpine:latest", + }, + }) + require.NoError(t, err) + + acceptedResp, ok := createResp.(oapi.CreateImage202JSONResponse) + require.True(t, ok, "expected 202 accepted response") + + img := oapi.Image(acceptedResp) + require.Equal(t, "docker.io/library/alpine:latest", img.Name) + require.NotEmpty(t, img.Digest, "digest should be populated immediately") + t.Logf("Image created: name=%s, digest=%s, initial_status=%s, queue_position=%v", + img.Name, img.Digest, img.Status, img.QueuePosition) + + // Construct digest reference for polling: repository@digest + // GetImage expects format like "docker.io/library/alpine@sha256:..." + digestRef := "docker.io/library/alpine@" + img.Digest + t.Logf("Polling with digest reference: %s", digestRef) + + // Poll until ready using digest (tag symlink doesn't exist until status=ready) + t.Log("Polling for completion...") + lastStatus := img.Status + lastQueuePos := getQueuePos(img.QueuePosition) + + for i := 0; i < 3000; i++ { + getResp, err := svc.GetImage(ctx, oapi.GetImageRequestObject{Name: digestRef}) + require.NoError(t, err) + + imgResp, ok := getResp.(oapi.GetImage200JSONResponse) + if !ok { + t.Fatalf("expected 200 response, got %T: %+v", getResp, getResp) + } + + currentImg := oapi.Image(imgResp) + currentQueuePos := getQueuePos(currentImg.QueuePosition) + + // Log when status or queue position changes + if currentImg.Status != lastStatus || currentQueuePos != lastQueuePos { + t.Logf("Update: status=%s, queue_position=%v", currentImg.Status, formatQueuePos(currentImg.QueuePosition)) + + // Queue position should only decrease (never increase) + if lastQueuePos > 0 && currentQueuePos > lastQueuePos { + t.Errorf("Queue position increased: %d -> %d", lastQueuePos, currentQueuePos) + } + + lastStatus = currentImg.Status + lastQueuePos = currentQueuePos + } + + if currentImg.Status == oapi.ImageStatus(images.StatusReady) { + t.Log("Build complete!") + require.NotNil(t, currentImg.SizeBytes) + require.Greater(t, *currentImg.SizeBytes, int64(0)) + require.Nil(t, currentImg.Error) + return + } + + if currentImg.Status == oapi.ImageStatus(images.StatusFailed) { + errMsg := "" + if currentImg.Error != nil { + errMsg = *currentImg.Error + } + t.Fatalf("Build failed: %s", errMsg) + } + + time.Sleep(10 * time.Millisecond) + } + + t.Fatal("Build did not complete within 30 seconds") +} + +func TestCreateImage_InvalidTag(t *testing.T) { + svc := newTestService(t) + ctx := ctx() + + t.Log("Creating image with invalid tag...") + createResp, err := svc.CreateImage(ctx, oapi.CreateImageRequestObject{ + Body: &oapi.CreateImageRequest{ + Name: "docker.io/library/busybox:foobar", + }, + }) + require.NoError(t, err) + + // With go-containerregistry, manifest validation happens synchronously + // Invalid tags fail immediately with 404 (manifest not found) + errorResp, ok := createResp.(oapi.CreateImage404JSONResponse) + require.True(t, ok, "expected 404 not found response for invalid tag") + + errObj := oapi.Error(errorResp) + require.Equal(t, "not_found", errObj.Code) + t.Logf("Got expected error: code=%s message=%s", errObj.Code, errObj.Message) +} + +func TestCreateImage_InvalidName(t *testing.T) { + svc := newTestService(t) + ctx := ctx() + + invalidNames := []string{ + "invalid::", + "has spaces", + "", + } + + for _, name := range invalidNames { + t.Run(name, func(t *testing.T) { + createResp, err := svc.CreateImage(ctx, oapi.CreateImageRequestObject{ + Body: &oapi.CreateImageRequest{Name: name}, + }) + require.NoError(t, err) + + badReq, ok := createResp.(oapi.CreateImage400JSONResponse) + require.True(t, ok, "expected 400 bad request for invalid name: %s", name) + require.Equal(t, "invalid_name", badReq.Code) + }) + } +} + +func TestCreateImage_Idempotent(t *testing.T) { + svc := newTestService(t) + ctx := ctx() + + // Create first image to occupy queue position 0 + t.Log("Creating first image (busybox) to occupy queue...") + _, err := svc.CreateImage(ctx, oapi.CreateImageRequestObject{ + Body: &oapi.CreateImageRequest{Name: "docker.io/library/busybox:latest"}, + }) + require.NoError(t, err) + + imageName := "docker.io/library/alpine:3.18" + + // First call - should create and queue at position 1 + t.Log("First CreateImage call (alpine)...") + resp1, err := svc.CreateImage(ctx, oapi.CreateImageRequestObject{ + Body: &oapi.CreateImageRequest{Name: imageName}, + }) + require.NoError(t, err) + + accepted1, ok := resp1.(oapi.CreateImage202JSONResponse) + require.True(t, ok, "expected 202 response") + img1 := oapi.Image(accepted1) + require.Equal(t, imageName, img1.Name) + require.NotEmpty(t, img1.Digest, "digest should be populated immediately") + require.Equal(t, oapi.ImageStatus(images.StatusPending), img1.Status) + require.NotNil(t, img1.QueuePosition, "should have queue position") + require.Equal(t, 1, *img1.QueuePosition, "should be at position 1") + t.Logf("First call: name=%s, digest=%s, status=%s, queue_position=%v", img1.Name, img1.Digest, img1.Status, formatQueuePos(img1.QueuePosition)) + + // Second call immediately - should return existing with same queue position + t.Log("Second CreateImage call (immediate duplicate)...") + resp2, err := svc.CreateImage(ctx, oapi.CreateImageRequestObject{ + Body: &oapi.CreateImageRequest{Name: imageName}, + }) + require.NoError(t, err) + + accepted2, ok := resp2.(oapi.CreateImage202JSONResponse) + require.True(t, ok, "expected 202 response") + img2 := oapi.Image(accepted2) + require.Equal(t, imageName, img2.Name) + require.Equal(t, img1.Digest, img2.Digest, "should have same digest") + + // Log actual status to see what's happening + t.Logf("Second call: digest=%s, status=%s, queue_position=%v, error=%v", + img2.Digest, img2.Status, formatQueuePos(img2.QueuePosition), img2.Error) + + // If it failed, we need to see why + if img2.Status == oapi.ImageStatus(images.StatusFailed) { + if img2.Error != nil { + t.Logf("Build failed with error: %s", *img2.Error) + } + t.Fatal("Build failed - this is the root cause of test failures") + } + + require.Equal(t, oapi.ImageStatus(images.StatusPending), img2.Status) + require.NotNil(t, img2.QueuePosition, "should have queue position") + require.Equal(t, 1, *img2.QueuePosition, "should still be at position 1") + + // Construct digest reference: repository@digest + // Extract repository from imageName (strip tag part) + repository := strings.Split(imageName, ":")[0] + digestRef := repository + "@" + img1.Digest + t.Logf("Polling with digest reference: %s", digestRef) + + // Wait for build to complete - poll by digest (tag symlink doesn't exist until status=ready) + t.Log("Waiting for build to complete...") + for i := 0; i < 3000; i++ { + getResp, err := svc.GetImage(ctx, oapi.GetImageRequestObject{Name: digestRef}) + require.NoError(t, err) + + imgResp, ok := getResp.(oapi.GetImage200JSONResponse) + require.True(t, ok, "expected 200 response") + + currentImg := oapi.Image(imgResp) + + if currentImg.Status == oapi.ImageStatus(images.StatusReady) { + t.Log("Build complete!") + break + } + + if currentImg.Status == oapi.ImageStatus(images.StatusFailed) { + errMsg := "" + if currentImg.Error != nil { + errMsg = *currentImg.Error + } + t.Fatalf("Build failed: %s", errMsg) + } + + time.Sleep(10 * time.Millisecond) + } + + // Third call after completion - should return ready image with no queue position + t.Log("Third CreateImage call (after completion)...") + resp3, err := svc.CreateImage(ctx, oapi.CreateImageRequestObject{ + Body: &oapi.CreateImageRequest{Name: imageName}, + }) + require.NoError(t, err) + + accepted3, ok := resp3.(oapi.CreateImage202JSONResponse) + require.True(t, ok, "expected 202 response") + img3 := oapi.Image(accepted3) + require.Equal(t, imageName, img3.Name) + require.Equal(t, oapi.ImageStatus(images.StatusReady), img3.Status, "should return ready image") + require.Nil(t, img3.QueuePosition, "ready image should have no queue position") + require.NotNil(t, img3.SizeBytes) + require.Greater(t, *img3.SizeBytes, int64(0)) + t.Logf("Third call: status=%s, queue_position=%v, size=%d", + img3.Status, formatQueuePos(img3.QueuePosition), *img3.SizeBytes) + + t.Log("Idempotency test passed!") +} + +func getQueuePos(pos *int) int { + if pos == nil { + return 0 + } + return *pos +} + +func formatQueuePos(pos *int) string { + if pos == nil { + return "none" + } + return fmt.Sprintf("%d", *pos) +} + + diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index 82976bcb..31048d7c 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -14,7 +14,7 @@ import ( func (s *ApiService) ListInstances(ctx context.Context, request oapi.ListInstancesRequestObject) (oapi.ListInstancesResponseObject, error) { log := logger.FromContext(ctx) - insts, err := s.InstanceManager.ListInstances(ctx) + domainInsts, err := s.InstanceManager.ListInstances(ctx) if err != nil { log.Error("failed to list instances", "error", err) return oapi.ListInstances500JSONResponse{ @@ -22,22 +22,34 @@ func (s *ApiService) ListInstances(ctx context.Context, request oapi.ListInstanc Message: "failed to list instances", }, nil } - return oapi.ListInstances200JSONResponse(insts), nil + + oapiInsts := make([]oapi.Instance, len(domainInsts)) + for i, inst := range domainInsts { + oapiInsts[i] = instanceToOAPI(inst) + } + + return oapi.ListInstances200JSONResponse(oapiInsts), nil } // CreateInstance creates and starts a new instance func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInstanceRequestObject) (oapi.CreateInstanceResponseObject, error) { log := logger.FromContext(ctx) - inst, err := s.InstanceManager.CreateInstance(ctx, *request.Body) + domainReq := instances.CreateInstanceRequest{ + Id: request.Body.Id, + Name: request.Body.Name, + Image: request.Body.Image, + } + + inst, err := s.InstanceManager.CreateInstance(ctx, domainReq) if err != nil { - log.Error("failed to create instance", "error", err, "name", request.Body.Name) + log.Error("failed to create instance", "error", err, "image", request.Body.Image) return oapi.CreateInstance500JSONResponse{ Code: "internal_error", Message: "failed to create instance", }, nil } - return oapi.CreateInstance201JSONResponse(*inst), nil + return oapi.CreateInstance201JSONResponse(instanceToOAPI(*inst)), nil } // GetInstance gets instance details @@ -60,9 +72,11 @@ func (s *ApiService) GetInstance(ctx context.Context, request oapi.GetInstanceRe }, nil } } - return oapi.GetInstance200JSONResponse(*inst), nil + return oapi.GetInstance200JSONResponse(instanceToOAPI(*inst)), nil } + + // DeleteInstance stops and deletes an instance func (s *ApiService) DeleteInstance(ctx context.Context, request oapi.DeleteInstanceRequestObject) (oapi.DeleteInstanceResponseObject, error) { log := logger.FromContext(ctx) @@ -111,7 +125,7 @@ func (s *ApiService) StandbyInstance(ctx context.Context, request oapi.StandbyIn }, nil } } - return oapi.StandbyInstance200JSONResponse(*inst), nil + return oapi.StandbyInstance200JSONResponse(instanceToOAPI(*inst)), nil } // RestoreInstance restores an instance from standby @@ -139,7 +153,7 @@ func (s *ApiService) RestoreInstance(ctx context.Context, request oapi.RestoreIn }, nil } } - return oapi.RestoreInstance200JSONResponse(*inst), nil + return oapi.RestoreInstance200JSONResponse(instanceToOAPI(*inst)), nil } // GetInstanceLogs streams instance logs @@ -182,7 +196,11 @@ func (s *ApiService) GetInstanceLogs(ctx context.Context, request oapi.GetInstan func (s *ApiService) AttachVolume(ctx context.Context, request oapi.AttachVolumeRequestObject) (oapi.AttachVolumeResponseObject, error) { log := logger.FromContext(ctx) - inst, err := s.InstanceManager.AttachVolume(ctx, request.Id, request.VolumeId, *request.Body) + domainReq := instances.AttachVolumeRequest{ + MountPath: request.Body.MountPath, + } + + inst, err := s.InstanceManager.AttachVolume(ctx, request.Id, request.VolumeId, domainReq) if err != nil { switch { case errors.Is(err, instances.ErrNotFound): @@ -198,7 +216,7 @@ func (s *ApiService) AttachVolume(ctx context.Context, request oapi.AttachVolume }, nil } } - return oapi.AttachVolume200JSONResponse(*inst), nil + return oapi.AttachVolume200JSONResponse(instanceToOAPI(*inst)), nil } // DetachVolume detaches a volume from an instance @@ -221,6 +239,14 @@ func (s *ApiService) DetachVolume(ctx context.Context, request oapi.DetachVolume }, nil } } - return oapi.DetachVolume200JSONResponse(*inst), nil + return oapi.DetachVolume200JSONResponse(instanceToOAPI(*inst)), nil } +func instanceToOAPI(inst instances.Instance) oapi.Instance { + return oapi.Instance{ + Id: inst.Id, + Name: inst.Name, + Image: inst.Image, + CreatedAt: inst.CreatedAt, + } +} \ No newline at end of file diff --git a/cmd/api/api/volumes.go b/cmd/api/api/volumes.go index 81ba2d4f..b1285bed 100644 --- a/cmd/api/api/volumes.go +++ b/cmd/api/api/volumes.go @@ -13,7 +13,7 @@ import ( func (s *ApiService) ListVolumes(ctx context.Context, request oapi.ListVolumesRequestObject) (oapi.ListVolumesResponseObject, error) { log := logger.FromContext(ctx) - vols, err := s.VolumeManager.ListVolumes(ctx) + domainVols, err := s.VolumeManager.ListVolumes(ctx) if err != nil { log.Error("failed to list volumes", "error", err) return oapi.ListVolumes500JSONResponse{ @@ -21,14 +21,26 @@ func (s *ApiService) ListVolumes(ctx context.Context, request oapi.ListVolumesRe Message: "failed to list volumes", }, nil } - return oapi.ListVolumes200JSONResponse(vols), nil + + oapiVols := make([]oapi.Volume, len(domainVols)) + for i, vol := range domainVols { + oapiVols[i] = volumeToOAPI(vol) + } + + return oapi.ListVolumes200JSONResponse(oapiVols), nil } // CreateVolume creates a new volume func (s *ApiService) CreateVolume(ctx context.Context, request oapi.CreateVolumeRequestObject) (oapi.CreateVolumeResponseObject, error) { log := logger.FromContext(ctx) - vol, err := s.VolumeManager.CreateVolume(ctx, *request.Body) + domainReq := volumes.CreateVolumeRequest{ + Name: request.Body.Name, + SizeGb: request.Body.SizeGb, + Id: request.Body.Id, + } + + vol, err := s.VolumeManager.CreateVolume(ctx, domainReq) if err != nil { log.Error("failed to create volume", "error", err, "name", request.Body.Name) return oapi.CreateVolume500JSONResponse{ @@ -36,7 +48,7 @@ func (s *ApiService) CreateVolume(ctx context.Context, request oapi.CreateVolume Message: "failed to create volume", }, nil } - return oapi.CreateVolume201JSONResponse(*vol), nil + return oapi.CreateVolume201JSONResponse(volumeToOAPI(*vol)), nil } // GetVolume gets volume details @@ -59,7 +71,7 @@ func (s *ApiService) GetVolume(ctx context.Context, request oapi.GetVolumeReques }, nil } } - return oapi.GetVolume200JSONResponse(*vol), nil + return oapi.GetVolume200JSONResponse(volumeToOAPI(*vol)), nil } // DeleteVolume deletes a volume @@ -90,3 +102,12 @@ func (s *ApiService) DeleteVolume(ctx context.Context, request oapi.DeleteVolume return oapi.DeleteVolume204Response{}, nil } +func volumeToOAPI(vol volumes.Volume) oapi.Volume { + return oapi.Volume{ + Id: vol.Id, + Name: vol.Name, + SizeGb: vol.SizeGb, + CreatedAt: vol.CreatedAt, + } +} + diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go index 9ed6451b..b57418f3 100644 --- a/cmd/api/config/config.go +++ b/cmd/api/config/config.go @@ -2,19 +2,20 @@ package config import ( "os" + "strconv" "github.com/joho/godotenv" ) type Config struct { - Port string - DataDir string - BridgeName string - SubnetCIDR string - SubnetGateway string - ContainerdSocket string - JwtSecret string - DNSServer string + Port string + DataDir string + BridgeName string + SubnetCIDR string + SubnetGateway string + JwtSecret string + DNSServer string + MaxConcurrentBuilds int } // Load loads configuration from environment variables @@ -24,14 +25,14 @@ func Load() *Config { _ = godotenv.Load() cfg := &Config{ - Port: getEnv("PORT", "8080"), - DataDir: getEnv("DATA_DIR", "/var/lib/hypeman"), - BridgeName: getEnv("BRIDGE_NAME", "vmbr0"), - SubnetCIDR: getEnv("SUBNET_CIDR", "192.168.100.0/24"), - SubnetGateway: getEnv("SUBNET_GATEWAY", "192.168.100.1"), - ContainerdSocket: getEnv("CONTAINERD_SOCKET", "/run/containerd/containerd.sock"), - JwtSecret: getEnv("JWT_SECRET", ""), - DNSServer: getEnv("DNS_SERVER", "1.1.1.1"), + Port: getEnv("PORT", "8080"), + DataDir: getEnv("DATA_DIR", "/var/lib/hypeman"), + BridgeName: getEnv("BRIDGE_NAME", "vmbr0"), + SubnetCIDR: getEnv("SUBNET_CIDR", "192.168.100.0/24"), + SubnetGateway: getEnv("SUBNET_GATEWAY", "192.168.100.1"), + JwtSecret: getEnv("JWT_SECRET", ""), + DNSServer: getEnv("DNS_SERVER", "1.1.1.1"), + MaxConcurrentBuilds: getEnvInt("MAX_CONCURRENT_BUILDS", 1), } return cfg @@ -44,3 +45,12 @@ func getEnv(key, defaultValue string) string { return defaultValue } +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intVal, err := strconv.Atoi(value); err == nil { + return intVal + } + } + return defaultValue +} + diff --git a/cmd/api/wire_gen.go b/cmd/api/wire_gen.go index 1fea4142..e254a4a6 100644 --- a/cmd/api/wire_gen.go +++ b/cmd/api/wire_gen.go @@ -28,7 +28,10 @@ func initializeApp() (*application, func(), error) { logger := providers.ProvideLogger() context := providers.ProvideContext(logger) config := providers.ProvideConfig() - manager := providers.ProvideImageManager(config) + manager, err := providers.ProvideImageManager(config) + if err != nil { + return nil, nil, err + } instancesManager := providers.ProvideInstanceManager(config) volumesManager := providers.ProvideVolumeManager(config) apiService := api.New(config, manager, instancesManager, volumesManager) diff --git a/go.mod b/go.mod index ab83bfc6..87a6cb87 100644 --- a/go.mod +++ b/go.mod @@ -3,33 +3,63 @@ module github.com/onkernel/hypeman go 1.25.4 require ( + github.com/distribution/reference v0.6.0 github.com/getkin/kin-openapi v0.133.0 github.com/ghodss/yaml v1.0.0 github.com/go-chi/chi/v5 v5.2.3 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/go-containerregistry v0.20.6 github.com/google/wire v0.7.0 github.com/joho/godotenv v1.5.1 github.com/oapi-codegen/nethttp-middleware v1.1.2 github.com/oapi-codegen/runtime v1.1.2 + github.com/opencontainers/image-spec v1.1.1 + github.com/opencontainers/runtime-spec v1.2.1 + github.com/opencontainers/umoci v0.6.0 github.com/stretchr/testify v1.11.1 golang.org/x/sync v0.17.0 ) require ( + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/apex/log v1.9.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect + github.com/cyphar/filepath-securejoin v0.5.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/google/uuid v1.5.0 // indirect + github.com/go-test/deep v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/rootless-containers/proto/go-proto v0.0.0-20230421021042-4cd87ebadd67 // indirect + github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect + github.com/vbatts/go-mtree v0.6.1-0.20250911112631-8307d76bc1b9 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/sys v0.37.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 2ced6493..fde2cb2e 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,99 @@ +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/apex/log v1.9.0 h1:FHtw/xuaM8AgmvDDTI9fiwoAL25Sq2cxojnZICUU8l0= +github.com/apex/log v1.9.0/go.mod h1:m82fZlWIuiWzWP04XCTXmnX0xRkYYbCdYn8jbJeLBEA= +github.com/apex/logs v1.0.0/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo= +github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE= +github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= +github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +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/cyphar/filepath-securejoin v0.5.0 h1:hIAhkRBMQ8nIeuVwcAoymp7MY4oherZdAxD+m0u9zaw= +github.com/cyphar/filepath-securejoin v0.5.0/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +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/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/oapi-codegen/nethttp-middleware v1.1.2 h1:TQwEU3WM6ifc7ObBEtiJgbRPaCe513tvJpiMJjypVPA= @@ -46,27 +104,95 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +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/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= +github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/umoci v0.6.0 h1:Dsm4beJpglN5y2E2EUSZZcNey4Ml4+nKepvwLQwgIec= +github.com/opencontainers/umoci v0.6.0/go.mod h1:2DS3cxVN9pRJGYaCK5mnmmwVKV5vd9r6HIYAV0IvdbI= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rootless-containers/proto/go-proto v0.0.0-20230421021042-4cd87ebadd67 h1:58jvc5cZ+hGKidQ4Z37/+rj9eQxRRjOOsqNEwPSZXR4= +github.com/rootless-containers/proto/go-proto v0.0.0-20230421021042-4cd87ebadd67/go.mod h1:LLjEAc6zmycfeN7/1fxIphWQPjHpTt7ElqT7eVf8e4A= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= +github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= +github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= 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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= +github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= +github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= +github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj52Uc= +github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= +github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= +github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/vbatts/go-mtree v0.6.1-0.20250911112631-8307d76bc1b9 h1:R6l9BtUe83abUGu1YKGkfa17wMMFLt6mhHVQ8MxpfRE= +github.com/vbatts/go-mtree v0.6.1-0.20250911112631-8307d76bc1b9/go.mod h1:W7bcG9PCn6lFY+ljGlZxx9DONkxL3v8a7HyN+PrSrjA= +github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= +github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/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.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/lib/images/README.md b/lib/images/README.md new file mode 100644 index 00000000..ad698294 --- /dev/null +++ b/lib/images/README.md @@ -0,0 +1,138 @@ +# Image Manager + +Converts OCI images to bootable erofs disks for Cloud Hypervisor VMs. + +## Architecture + +``` +OCI Registry → go-containerregistry → OCI Layout → umoci → rootfs/ → mkfs.erofs → disk.erofs +``` + +## Design Decisions + +### Why go-containerregistry? (oci.go) + +**What:** Pull OCI images from any registry (Docker Hub, ghcr.io, etc.) + +**Why:** +- Lightweight library from Google (used by ko, crane, etc.) +- Works directly with registries (no daemon required) +- Can propagate errors from registry (like 429) +- Supports all registry authentication methods + +**Alternative:** containers/image - has automatic retry logic that delays error reporting, can't fail fast for registry rate limits. Heavier, supporting more use cases in comparison to go-containerregistry. + +### Why umoci? (oci.go) + +**What:** Unpack OCI image layers in userspace + +**Why:** +- Purpose-built for rootless OCI manipulation (official OpenContainers project) +- Handles OCI layer semantics (whiteouts, layer ordering) correctly +- Designed to work without root privileges + +**Alternative:** With Docker API, the daemon (running as root) mounts image layers using overlayfs, then exports the merged filesystem. Users get the result without needing root themselves but it still has the dependency on Docker and does actually mount the overlays to get the merged filesystem. With umoci, layers are merged in userspace by extracting each tar layer sequentially and applying changes (including whiteouts for deletions). No kernel mount needed, fully rootless. Umoci was chosen because it's purpose-built for this use case and embeddable with the go program. + +### Why erofs? (disk.go) + +**What:** erofs (Enhanced Read-Only File System) with LZ4 compression + +**Why:** +- Purpose-built for read-only overlay lowerdir +- Fast compression (~20-25% space savings) +- Fast decompression at VM boot +- Lower memory footprint than ext4 +- No journal/inode overhead + +**Options:** +- `-zlz4` - Fast compression + +**Alternative:** ext4 without journal works but erofs is optimized for this exact use case + +## Filesystem Layout (storage.go, oci.go) + +Content-addressable storage with tag symlinks (similar to Docker/Unikraft): + +``` +/var/lib/hypeman/ + images/ + docker.io/library/alpine/ + abc123def456.../ # Digest (sha256:abc123def456...) + metadata.json # Status, entrypoint, cmd, env + rootfs.erofs # Compressed read-only disk + def456abc123.../ # Another version (digest) + metadata.json + rootfs.erofs + latest -> abc123def456... # Tag symlink to digest + 3.18 -> def456abc123... # Another tag + system/ + oci-cache/ # Shared OCI layout for all images + index.json # Manifest index with digest-based tags + blobs/sha256/ + 2d35eb... # Layer blobs (shared across all images!) + 44cf07... # Another layer + 706db5... # Config blob for alpine + abc123def456... # Manifest for alpine:latest +``` + +**Benefits:** +- Content-addressable: Digests are immutable, same content stored once +- Tag mutability: Tags (symlinks) can point to different digests over time +- Deduplication: Multiple tags can point to same digest +- Natural hierarchy: All versions of an image grouped under repository +- Easy inspection: Clear which digest belongs to which image +- Layer caching: All images share the same blob storage, layers deduplicated automatically + +**Design:** +- Images stored by manifest digest (content hash) +- Tags are filesystem symlinks pointing to digest directories +- Manifest always inspected upfront to discover digest (validates existence) +- Pulling same tag twice updates the symlink if digest changed +- OCI cache uses digest hex as layout tag for true content-addressable caching +- Shared blob storage enables automatic layer deduplication across all images +- Old digests remain until explicitly garbage collected +- Symlinks only created after successful build (status: ready) + +## Reference Handling (reference.go) + +Two types for type-safe image reference handling: + +**`NormalizedRef`** - Validated format (parsing only): +```go +normalized, err := ParseNormalizedRef("alpine") +// Normalizes to "docker.io/library/alpine:latest" +``` + +**`ResolvedRef`** - Normalized + manifest digest (network call): +```go +resolved, err := normalized.Resolve(ctx, ociClient) +// Now has digest from registry inspection + +resolved.Repository() // "docker.io/library/alpine" +resolved.Tag() // "latest" +resolved.Digest() // "sha256:abc123..." (always present) +``` + +Validation via `github.com/distribution/reference`: +- `alpine` → `docker.io/library/alpine:latest` +- `alpine:3.18` → `docker.io/library/alpine:3.18` +- `alpine@sha256:abc123...` → digest validated against registry +- Rejects invalid formats (returns 400) + +## Build Tags + +Requires `-tags containers_image_openpgp` for umoci dependency compatibility. + +## Registry Authentication + +go-containerregistry automatically uses `~/.docker/config.json` via `authn.DefaultKeychain`. + +```bash +# Login to Docker Hub (avoid rate limits) +docker login + +# Works for any registry +docker login ghcr.io +``` + +No code changes needed - credentials are automatically discovered. \ No newline at end of file diff --git a/lib/images/disk.go b/lib/images/disk.go new file mode 100644 index 00000000..15ec66bf --- /dev/null +++ b/lib/images/disk.go @@ -0,0 +1,49 @@ +package images + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// convertToErofs converts a rootfs directory to an erofs disk image using mkfs.erofs +func convertToErofs(rootfsDir, diskPath string) (int64, error) { + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(diskPath), 0755); err != nil { + return 0, fmt.Errorf("create disk parent dir: %w", err) + } + + // Create erofs image with LZ4 fast compression + // -zlz4: LZ4 fast compression (~20-25% space savings, faster builds) + // erofs doesn't need pre-allocation, creates file directly + cmd := exec.Command("mkfs.erofs", "-zlz4", diskPath, rootfsDir) + output, err := cmd.CombinedOutput() + if err != nil { + return 0, fmt.Errorf("mkfs.erofs failed: %w, output: %s", err, output) + } + + // Get actual disk size + stat, err := os.Stat(diskPath) + if err != nil { + return 0, fmt.Errorf("stat disk: %w", err) + } + + return stat.Size(), nil +} + +// dirSize calculates the total size of a directory +func dirSize(path string) (int64, error) { + var size int64 + err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + size += info.Size() + } + return nil + }) + return size, err +} + diff --git a/lib/images/errors.go b/lib/images/errors.go index 29baf9e9..ee7679e8 100644 --- a/lib/images/errors.go +++ b/lib/images/errors.go @@ -1,9 +1,28 @@ package images -import "errors" +import ( + "errors" + "fmt" + "strings" +) var ( - ErrNotFound = errors.New("image not found") - ErrAlreadyExists = errors.New("image already exists") + ErrNotFound = errors.New("image not found") + ErrInvalidName = errors.New("invalid image name") ) +// wrapRegistryError checks if the error is a registry 404 error and wraps it as ErrNotFound. +// go-containerregistry returns transport errors with specific codes for registry issues. +func wrapRegistryError(err error) error { + if err == nil { + return nil + } + errStr := err.Error() + if strings.Contains(errStr, "NAME_UNKNOWN") || + strings.Contains(errStr, "MANIFEST_UNKNOWN") || + strings.Contains(errStr, "404") || + strings.Contains(errStr, "not found") { + return fmt.Errorf("%w: %v", ErrNotFound, err) + } + return err +} diff --git a/lib/images/manager.go b/lib/images/manager.go index 0f7f2104..84e1f61d 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -3,56 +3,315 @@ package images import ( "context" "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) - "github.com/onkernel/hypeman/lib/oapi" +const ( + StatusPending = "pending" + StatusPulling = "pulling" + StatusConverting = "converting" + StatusReady = "ready" + StatusFailed = "failed" ) -// Manager handles image lifecycle operations type Manager interface { - ListImages(ctx context.Context) ([]oapi.Image, error) - CreateImage(ctx context.Context, req oapi.CreateImageRequest) (*oapi.Image, error) - GetImage(ctx context.Context, id string) (*oapi.Image, error) - DeleteImage(ctx context.Context, id string) error + ListImages(ctx context.Context) ([]Image, error) + CreateImage(ctx context.Context, req CreateImageRequest) (*Image, error) + GetImage(ctx context.Context, name string) (*Image, error) + DeleteImage(ctx context.Context, name string) error + RecoverInterruptedBuilds() } type manager struct { - dataDir string + dataDir string + ociClient *ociClient + queue *BuildQueue + createMu sync.Mutex } // NewManager creates a new image manager -func NewManager(dataDir string) Manager { - return &manager{ - dataDir: dataDir, +func NewManager(dataDir string, maxConcurrentBuilds int) (Manager, error) { + // Create cache directory under dataDir for OCI layouts + cacheDir := filepath.Join(dataDir, "system", "oci-cache") + ociClient, err := newOCIClient(cacheDir) + if err != nil { + return nil, fmt.Errorf("create oci client: %w", err) + } + + m := &manager{ + dataDir: dataDir, + ociClient: ociClient, + queue: NewBuildQueue(maxConcurrentBuilds), + } + m.RecoverInterruptedBuilds() + return m, nil +} + +func (m *manager) ListImages(ctx context.Context) ([]Image, error) { + metas, err := listAllTags(m.dataDir) + if err != nil { + return nil, fmt.Errorf("list tags: %w", err) + } + + images := make([]Image, 0, len(metas)) + for _, meta := range metas { + images = append(images, *meta.toImage()) + } + + return images, nil +} + +func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Image, error) { + // Parse and normalize + normalized, err := ParseNormalizedRef(req.Name) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidName, err.Error()) + } + + // Resolve to get digest (validates existence) + // Add a 2-second timeout to ensure fast failure on rate limits or errors + resolveCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + ref, err := normalized.Resolve(resolveCtx, m.ociClient) + if err != nil { + return nil, fmt.Errorf("resolve manifest: %w", err) + } + + m.createMu.Lock() + defer m.createMu.Unlock() + + // Check if we already have this digest (deduplication) + if meta, err := readMetadata(m.dataDir, ref.Repository(), ref.DigestHex()); err == nil { + // We have this digest already + if meta.Status == StatusReady && ref.Tag() != "" { + // Update tag symlink to point to current digest + // (handles case where tag moved to new digest) + createTagSymlink(m.dataDir, ref.Repository(), ref.Tag(), ref.DigestHex()) + } + img := meta.toImage() + // Add queue position if pending + if meta.Status == StatusPending { + img.QueuePosition = m.queue.GetPosition(meta.Digest) + } + return img, nil } + + // Don't have this digest yet, queue the build + return m.createAndQueueImage(ref) +} + +func (m *manager) createAndQueueImage(ref *ResolvedRef) (*Image, error) { + meta := &imageMetadata{ + Name: ref.String(), + Digest: ref.Digest(), + Status: StatusPending, + Request: &CreateImageRequest{Name: ref.String()}, + CreatedAt: time.Now(), + } + + // Write initial metadata + if err := writeMetadata(m.dataDir, ref.Repository(), ref.DigestHex(), meta); err != nil { + return nil, fmt.Errorf("write initial metadata: %w", err) + } + + // Enqueue the build using digest as the queue key for deduplication + queuePos := m.queue.Enqueue(ref.Digest(), CreateImageRequest{Name: ref.String()}, func() { + m.buildImage(context.Background(), ref) + }) + + img := meta.toImage() + if queuePos > 0 { + img.QueuePosition = &queuePos + } + return img, nil } -func (m *manager) ListImages(ctx context.Context) ([]oapi.Image, error) { - // TODO: implement - return []oapi.Image{}, nil +func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef) { + buildDir := filepath.Join(m.dataDir, "system", "builds", ref.String()) + tempDir := filepath.Join(buildDir, "rootfs") + + if err := os.MkdirAll(buildDir, 0755); err != nil { + m.updateStatusByDigest(ref, StatusFailed, fmt.Errorf("create build dir: %w", err)) + return + } + + defer func() { + // Clean up build directory after completion + os.RemoveAll(buildDir) + }() + + m.updateStatusByDigest(ref, StatusPulling, nil) + + // Pull the image (digest is always known, uses cache if already pulled) + result, err := m.ociClient.pullAndExport(ctx, ref.String(), ref.Digest(), tempDir) + if err != nil { + m.updateStatusByDigest(ref, StatusFailed, fmt.Errorf("pull and export: %w", err)) + return + } + + // Check if this digest already exists and is ready (deduplication) + if meta, err := readMetadata(m.dataDir, ref.Repository(), ref.DigestHex()); err == nil { + if meta.Status == StatusReady { + // Another build completed first, just update the tag symlink + if ref.Tag() != "" { + createTagSymlink(m.dataDir, ref.Repository(), ref.Tag(), ref.DigestHex()) + } + return + } + } + + m.updateStatusByDigest(ref, StatusConverting, nil) + + diskPath := digestPath(m.dataDir, ref.Repository(), ref.DigestHex()) + diskSize, err := convertToErofs(tempDir, diskPath) + if err != nil { + m.updateStatusByDigest(ref, StatusFailed, fmt.Errorf("convert to erofs: %w", err)) + return + } + + // Read current metadata to preserve request info + meta, err := readMetadata(m.dataDir, ref.Repository(), ref.DigestHex()) + if err != nil { + // Create new metadata if it doesn't exist + meta = &imageMetadata{ + Name: ref.String(), + Digest: ref.Digest(), + CreatedAt: time.Now(), + } + } + + // Update with final status + meta.Status = StatusReady + meta.Error = nil + meta.SizeBytes = diskSize + meta.Entrypoint = result.Metadata.Entrypoint + meta.Cmd = result.Metadata.Cmd + meta.Env = result.Metadata.Env + meta.WorkingDir = result.Metadata.WorkingDir + + if err := writeMetadata(m.dataDir, ref.Repository(), ref.DigestHex(), meta); err != nil { + m.updateStatusByDigest(ref, StatusFailed, fmt.Errorf("write final metadata: %w", err)) + return + } + + // Only create/update tag symlink on successful completion + if ref.Tag() != "" { + if err := createTagSymlink(m.dataDir, ref.Repository(), ref.Tag(), ref.DigestHex()); err != nil { + // Log error but don't fail the build + fmt.Fprintf(os.Stderr, "Warning: failed to create tag symlink: %v\n", err) + } + } } -func (m *manager) CreateImage(ctx context.Context, req oapi.CreateImageRequest) (*oapi.Image, error) { - // TODO: implement actual logic - // Example: check if already exists - exists := false - if exists { - return nil, ErrAlreadyExists +func (m *manager) updateStatusByDigest(ref *ResolvedRef, status string, err error) { + meta, readErr := readMetadata(m.dataDir, ref.Repository(), ref.DigestHex()) + if readErr != nil { + // Create new metadata if it doesn't exist + meta = &imageMetadata{ + Name: ref.String(), + Digest: ref.Digest(), + Status: status, + CreatedAt: time.Now(), + } + } else { + meta.Status = status + } + + if err != nil { + errorMsg := err.Error() + meta.Error = &errorMsg } - return nil, fmt.Errorf("image creation not yet implemented") + + writeMetadata(m.dataDir, ref.Repository(), ref.DigestHex(), meta) } -func (m *manager) GetImage(ctx context.Context, id string) (*oapi.Image, error) { - // TODO: implement actual logic - // For now, always return not found since we have no images - return nil, ErrNotFound +func (m *manager) RecoverInterruptedBuilds() { + metas, err := listAllTags(m.dataDir) + if err != nil { + return // Best effort + } + + // Sort by created_at to maintain FIFO order + sort.Slice(metas, func(i, j int) bool { + return metas[i].CreatedAt.Before(metas[j].CreatedAt) + }) + + for _, meta := range metas { + switch meta.Status { + case StatusPending, StatusPulling, StatusConverting: + if meta.Request != nil && meta.Digest != "" { + metaCopy := meta + normalized, err := ParseNormalizedRef(metaCopy.Name) + if err != nil { + continue + } + // Create a ResolvedRef since we already have the digest from metadata + ref := NewResolvedRef(normalized, metaCopy.Digest) + m.queue.Enqueue(metaCopy.Digest, *metaCopy.Request, func() { + m.buildImage(context.Background(), ref) + }) + } + } + } } -func (m *manager) DeleteImage(ctx context.Context, id string) error { - // TODO: implement actual logic - exists := false - if !exists { - return ErrNotFound +func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) { + // Parse and normalize the reference + ref, err := ParseNormalizedRef(name) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidName, err.Error()) } - return fmt.Errorf("delete image not yet implemented") + + repository := ref.Repository() + + var digestHex string + + if ref.IsDigest() { + // Direct digest lookup + digestHex = ref.DigestHex() + } else { + // Tag lookup - resolve symlink + tag := ref.Tag() + + digestHex, err = resolveTag(m.dataDir, repository, tag) + if err != nil { + return nil, err + } + } + + meta, err := readMetadata(m.dataDir, repository, digestHex) + if err != nil { + return nil, err + } + + img := meta.toImage() + + if meta.Status == StatusPending { + img.QueuePosition = m.queue.GetPosition(meta.Digest) + } + + return img, nil } +func (m *manager) DeleteImage(ctx context.Context, name string) error { + // Parse and normalize the reference + ref, err := ParseNormalizedRef(name) + if err != nil { + return fmt.Errorf("%w: %s", ErrInvalidName, err.Error()) + } + + // Only allow deleting by tag, not by digest + if ref.IsDigest() { + return fmt.Errorf("cannot delete by digest, use tag name instead") + } + + repository := ref.Repository() + tag := ref.Tag() + + return deleteTag(m.dataDir, repository, tag) +} diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go new file mode 100644 index 00000000..c844a074 --- /dev/null +++ b/lib/images/manager_test.go @@ -0,0 +1,376 @@ +package images + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCreateImage(t *testing.T) { + dataDir := t.TempDir() + mgr, err := NewManager(dataDir, 1) + require.NoError(t, err) + + ctx := context.Background() + req := CreateImageRequest{ + Name: "docker.io/library/alpine:latest", + } + + img, err := mgr.CreateImage(ctx, req) + require.NoError(t, err) + require.NotNil(t, img) + require.Equal(t, "docker.io/library/alpine:latest", img.Name) + + waitForReady(t, mgr, ctx, img.Name) + + img, err = mgr.GetImage(ctx, img.Name) + require.NoError(t, err) + require.Equal(t, StatusReady, img.Status) + require.NotNil(t, img.SizeBytes) + require.Greater(t, *img.SizeBytes, int64(0)) + require.NotEmpty(t, img.Digest) + require.Contains(t, img.Digest, "sha256:") + + // Check that digest directory exists + ref, err := ParseNormalizedRef(img.Name) + require.NoError(t, err) + digestHex := strings.SplitN(img.Digest, ":", 2)[1] + + // Check erofs disk file + diskPath := digestPath(dataDir, ref.Repository(), digestHex) + diskStat, err := os.Stat(diskPath) + require.NoError(t, err) + require.False(t, diskStat.IsDir(), "disk path should be a file") + require.Greater(t, diskStat.Size(), int64(1000000), "erofs disk should be at least 1MB") + require.Equal(t, diskStat.Size(), *img.SizeBytes, "disk size should match metadata") + t.Logf("EROFS disk: path=%s, size=%d bytes", diskPath, diskStat.Size()) + + // Check metadata file + metadataPath := metadataPath(dataDir, ref.Repository(), digestHex) + metaStat, err := os.Stat(metadataPath) + require.NoError(t, err) + require.False(t, metaStat.IsDir(), "metadata should be a file") + + // Read and verify metadata content + meta, err := readMetadata(dataDir, ref.Repository(), digestHex) + require.NoError(t, err) + require.Equal(t, img.Name, meta.Name) + require.Equal(t, img.Digest, meta.Digest) + require.Equal(t, StatusReady, meta.Status) + require.Nil(t, meta.Error) + require.Equal(t, diskStat.Size(), meta.SizeBytes) + require.NotEmpty(t, meta.Env, "should have environment variables") + t.Logf("Metadata: name=%s, digest=%s, status=%s, env_vars=%d", + meta.Name, meta.Digest, meta.Status, len(meta.Env)) + + // Check that tag symlink exists and points to correct digest + linkPath := tagSymlinkPath(dataDir, ref.Repository(), ref.Tag()) + linkStat, err := os.Lstat(linkPath) + require.NoError(t, err) + require.NotEqual(t, 0, linkStat.Mode()&os.ModeSymlink, "should be a symlink") + + // Verify symlink points to digest directory + linkTarget, err := os.Readlink(linkPath) + require.NoError(t, err) + require.Equal(t, digestHex, linkTarget, "symlink should point to digest") + t.Logf("Tag symlink: %s -> %s", linkPath, linkTarget) +} + +func TestCreateImageDifferentTag(t *testing.T) { + dataDir := t.TempDir() + mgr, err := NewManager(dataDir, 1) + require.NoError(t, err) + + ctx := context.Background() + req := CreateImageRequest{ + Name: "docker.io/library/alpine:3.18", + } + + img, err := mgr.CreateImage(ctx, req) + require.NoError(t, err) + require.NotNil(t, img) + require.Equal(t, "docker.io/library/alpine:3.18", img.Name) + + waitForReady(t, mgr, ctx, img.Name) + + img, err = mgr.GetImage(ctx, img.Name) + require.NoError(t, err) + require.NotEmpty(t, img.Digest) +} + +func TestCreateImageDuplicate(t *testing.T) { + dataDir := t.TempDir() + mgr, err := NewManager(dataDir, 1) + require.NoError(t, err) + + ctx := context.Background() + req := CreateImageRequest{ + Name: "docker.io/library/alpine:latest", + } + + img1, err := mgr.CreateImage(ctx, req) + require.NoError(t, err) + + waitForReady(t, mgr, ctx, img1.Name) + + // Re-fetch img1 to get the complete metadata including digest + img1, err = mgr.GetImage(ctx, img1.Name) + require.NoError(t, err) + require.NotEmpty(t, img1.Digest) + + // Second create should be idempotent and return existing image + img2, err := mgr.CreateImage(ctx, req) + require.NoError(t, err) + require.NotNil(t, img2) + require.Equal(t, img1.Name, img2.Name) + require.Equal(t, StatusReady, img2.Status) + require.Equal(t, img1.Digest, img2.Digest) // Same digest +} + +func TestListImages(t *testing.T) { + dataDir := t.TempDir() + mgr, err := NewManager(dataDir, 1) + require.NoError(t, err) + + ctx := context.Background() + + // Initially empty + images, err := mgr.ListImages(ctx) + require.NoError(t, err) + require.Len(t, images, 0) + + req1 := CreateImageRequest{ + Name: "docker.io/library/alpine:latest", + } + img1, err := mgr.CreateImage(ctx, req1) + require.NoError(t, err) + + waitForReady(t, mgr, ctx, img1.Name) + + // List should return one image + images, err = mgr.ListImages(ctx) + require.NoError(t, err) + require.Len(t, images, 1) + require.Equal(t, "docker.io/library/alpine:latest", images[0].Name) + require.Equal(t, StatusReady, images[0].Status) + require.NotEmpty(t, images[0].Digest) +} + +func TestGetImage(t *testing.T) { + dataDir := t.TempDir() + mgr, err := NewManager(dataDir, 1) + require.NoError(t, err) + + ctx := context.Background() + req := CreateImageRequest{ + Name: "docker.io/library/alpine:latest", + } + + created, err := mgr.CreateImage(ctx, req) + require.NoError(t, err) + + waitForReady(t, mgr, ctx, created.Name) + + img, err := mgr.GetImage(ctx, created.Name) + require.NoError(t, err) + require.NotNil(t, img) + require.Equal(t, created.Name, img.Name) + require.Equal(t, StatusReady, img.Status) + require.NotNil(t, img.SizeBytes) + require.NotEmpty(t, img.Digest) +} + +func TestGetImageNotFound(t *testing.T) { + dataDir := t.TempDir() + mgr, err := NewManager(dataDir, 1) + require.NoError(t, err) + + ctx := context.Background() + + _, err = mgr.GetImage(ctx, "nonexistent:latest") + require.ErrorIs(t, err, ErrNotFound) +} + +func TestDeleteImage(t *testing.T) { + dataDir := t.TempDir() + mgr, err := NewManager(dataDir, 1) + require.NoError(t, err) + + ctx := context.Background() + req := CreateImageRequest{ + Name: "docker.io/library/alpine:latest", + } + + created, err := mgr.CreateImage(ctx, req) + require.NoError(t, err) + + waitForReady(t, mgr, ctx, created.Name) + + // Get the digest before deleting + img, err := mgr.GetImage(ctx, created.Name) + require.NoError(t, err) + ref, err := ParseNormalizedRef(img.Name) + require.NoError(t, err) + digestHex := strings.SplitN(img.Digest, ":", 2)[1] + + err = mgr.DeleteImage(ctx, created.Name) + require.NoError(t, err) + + // Tag should be gone + _, err = mgr.GetImage(ctx, created.Name) + require.ErrorIs(t, err, ErrNotFound) + + // But digest directory should still exist + digestDir := digestPath(dataDir, ref.Repository(), digestHex) + _, err = os.Stat(digestDir) + require.NoError(t, err) +} + +func TestDeleteImageNotFound(t *testing.T) { + dataDir := t.TempDir() + mgr, err := NewManager(dataDir, 1) + require.NoError(t, err) + + ctx := context.Background() + + err = mgr.DeleteImage(ctx, "nonexistent:latest") + require.ErrorIs(t, err, ErrNotFound) +} + +func TestNormalizedRefParsing(t *testing.T) { + tests := []struct { + input string + expectRepo string + expectTag string + }{ + {"alpine", "docker.io/library/alpine", "latest"}, + {"alpine:3.18", "docker.io/library/alpine", "3.18"}, + {"docker.io/library/alpine:latest", "docker.io/library/alpine", "latest"}, + {"ghcr.io/myorg/myapp:v1.0.0", "ghcr.io/myorg/myapp", "v1.0.0"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + ref, err := ParseNormalizedRef(tt.input) + require.NoError(t, err) + + repo := ref.Repository() + require.Equal(t, tt.expectRepo, repo) + + tag := ref.Tag() + require.Equal(t, tt.expectTag, tag) + }) + } +} + +func TestLayerCaching(t *testing.T) { + dataDir := t.TempDir() + mgr, err := NewManager(dataDir, 1) + require.NoError(t, err) + ctx := context.Background() + + // 1. Pull alpine:latest by tag + t.Log("Pulling alpine:latest by tag...") + alpine1, err := mgr.CreateImage(ctx, CreateImageRequest{ + Name: "docker.io/library/alpine:latest", + }) + require.NoError(t, err) + require.NotEmpty(t, alpine1.Digest, "should have digest") + + // Wait for first pull to complete (poll by digest) + alpine1Ref := "docker.io/library/alpine@" + alpine1.Digest + waitForReady(t, mgr, ctx, alpine1Ref) + + // Count blobs after first pull + blobsDir := filepath.Join(dataDir, "system", "oci-cache", "blobs", "sha256") + blobsAfterFirst, err := countFiles(blobsDir) + require.NoError(t, err) + t.Logf("Blobs after first pull: %d", blobsAfterFirst) + require.Greater(t, blobsAfterFirst, 0, "should have downloaded blobs") + + // 2. Pull the SAME digest but reference it by digest + // This guarantees 100% layer overlap - tests cross-reference caching + t.Logf("Pulling same image by digest reference: %s", alpine1.Digest) + alpine2, err := mgr.CreateImage(ctx, CreateImageRequest{ + Name: alpine1Ref, // Pull by digest instead of tag + }) + require.NoError(t, err) + require.Equal(t, alpine1.Digest, alpine2.Digest, "should have same digest") + + // This should be instant - already cached + waitForReady(t, mgr, ctx, alpine1Ref) + + // Count blobs after second pull + blobsAfterSecond, err := countFiles(blobsDir) + require.NoError(t, err) + t.Logf("Blobs after second pull: %d", blobsAfterSecond) + + // 3. Verify layer caching worked - should add ZERO new blobs + blobsAdded := blobsAfterSecond - blobsAfterFirst + require.Equal(t, 0, blobsAdded, + "Pulling same digest with different reference should not download any new blobs (everything cached)") + + // 4. Verify both references work and point to functional images + alpine1Parsed, err := ParseNormalizedRef(alpine1.Name) + require.NoError(t, err) + alpine2Parsed, err := ParseNormalizedRef(alpine2.Name) + require.NoError(t, err) + + // Both should point to the same digest directory + digestHex := strings.TrimPrefix(alpine1.Digest, "sha256:") + disk1 := digestPath(dataDir, alpine1Parsed.Repository(), digestHex) + disk2 := digestPath(dataDir, alpine2Parsed.Repository(), digestHex) + + require.Equal(t, disk1, disk2, "both references should point to same disk") + + stat, err := os.Stat(disk1) + require.NoError(t, err) + require.Greater(t, stat.Size(), int64(0)) + + t.Logf("Layer caching verified: second pull reused all %d cached blobs", blobsAfterFirst) +} + +// countFiles counts the number of files in a directory +func countFiles(dir string) (int, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + return len(entries), nil +} + +// waitForReady waits for an image build to complete +func waitForReady(t *testing.T, mgr Manager, ctx context.Context, imageName string) { + for i := 0; i < 600; i++ { + img, err := mgr.GetImage(ctx, imageName) + if err != nil { + time.Sleep(100 * time.Millisecond) + continue + } + + if i%10 == 0 { + t.Logf("Status: %s", img.Status) + } + + if img.Status == StatusReady { + return + } + + if img.Status == StatusFailed { + errMsg := "" + if img.Error != nil { + errMsg = *img.Error + } + t.Fatalf("Build failed: %s", errMsg) + } + + time.Sleep(100 * time.Millisecond) + } + + t.Fatal("Build did not complete within 60 seconds") +} diff --git a/lib/images/oci.go b/lib/images/oci.go new file mode 100644 index 00000000..645b174d --- /dev/null +++ b/lib/images/oci.go @@ -0,0 +1,325 @@ +package images + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/opencontainers/image-spec/specs-go/v1" + rspec "github.com/opencontainers/runtime-spec/specs-go" + "github.com/opencontainers/umoci/oci/cas/dir" + "github.com/opencontainers/umoci/oci/casext" + "github.com/opencontainers/umoci/oci/layer" +) + +// ociClient handles OCI image operations without requiring Docker daemon +type ociClient struct { + cacheDir string +} + +// digestToLayoutTag converts a digest to a valid OCI layout tag. +// Uses just the hex portion without the algorithm prefix. +// Example: "sha256:abc123..." -> "abc123..." +func digestToLayoutTag(digest string) string { + // Extract just the hex hash after the colon + parts := strings.SplitN(digest, ":", 2) + if len(parts) == 2 { + return parts[1] + } + return digest // Fallback if no colon found +} + +// existsInLayout checks if a digest already exists in the OCI layout cache. +func (c *ociClient) existsInLayout(layoutTag string) bool { + casEngine, err := dir.Open(c.cacheDir) + if err != nil { + return false + } + defer casEngine.Close() + + engine := casext.NewEngine(casEngine) + descriptorPaths, err := engine.ResolveReference(context.Background(), layoutTag) + if err != nil { + return false + } + + return len(descriptorPaths) > 0 +} + +// newOCIClient creates a new OCI client +func newOCIClient(cacheDir string) (*ociClient, error) { + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return nil, fmt.Errorf("create cache dir: %w", err) + } + return &ociClient{cacheDir: cacheDir}, nil +} + +// inspectManifest synchronously inspects a remote image to get its digest +// without pulling the image. This is used for upfront digest discovery. +func (c *ociClient) inspectManifest(ctx context.Context, imageRef string) (string, error) { + ref, err := name.ParseReference(imageRef) + if err != nil { + return "", fmt.Errorf("parse image reference: %w", err) + } + + // Use system authentication (reads from ~/.docker/config.json, etc.) + // Default retry: only on network errors, max ~1.3s total + descriptor, err := remote.Head(ref, + remote.WithContext(ctx), + remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return "", fmt.Errorf("fetch manifest: %w", wrapRegistryError(err)) + } + + return descriptor.Digest.String(), nil +} + +// pullResult contains the metadata and digest from pulling an image +type pullResult struct { + Metadata *containerMetadata + Digest string // sha256:abc123... +} + +func (c *ociClient) pullAndExport(ctx context.Context, imageRef, digest, exportDir string) (*pullResult, error) { + // Use a shared OCI layout for all images to enable automatic layer caching + // The cacheDir itself is the OCI layout root with shared blobs/sha256/ directory + // The digest is ALWAYS known at this point (from inspectManifest or digest reference) + layoutTag := digestToLayoutTag(digest) + + // Check if this digest is already cached + if !c.existsInLayout(layoutTag) { + // Not cached, pull it using digest-based tag + if err := c.pullToOCILayout(ctx, imageRef, layoutTag); err != nil { + return nil, fmt.Errorf("pull to oci layout: %w", err) + } + } + // If cached, we skip the pull entirely + + // Extract metadata (from cache or freshly pulled) + meta, err := c.extractOCIMetadata(layoutTag) + if err != nil { + return nil, fmt.Errorf("extract metadata: %w", err) + } + + // Unpack layers to the export directory + if err := c.unpackLayers(ctx, layoutTag, exportDir); err != nil { + return nil, fmt.Errorf("unpack layers: %w", err) + } + + return &pullResult{ + Metadata: meta, + Digest: digest, + }, nil +} + +func (c *ociClient) pullToOCILayout(ctx context.Context, imageRef, layoutTag string) error { + ref, err := name.ParseReference(imageRef) + if err != nil { + return fmt.Errorf("parse image reference: %w", err) + } + + // Use system authentication (reads from ~/.docker/config.json, etc.) + // Default retry: only on network errors, max ~1.3s total + img, err := remote.Image(ref, + remote.WithContext(ctx), + remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + // Rate limits fail here immediately (429 is not retried by default) + return fmt.Errorf("fetch image manifest: %w", wrapRegistryError(err)) + } + + // Open or create OCI layout directory + path, err := layout.FromPath(c.cacheDir) + if err != nil { + // If layout doesn't exist, create it + path, err = layout.Write(c.cacheDir, empty.Index) + if err != nil { + return fmt.Errorf("create oci layout: %w", err) + } + } + + // Append image to layout - THIS is where actual layer data is downloaded + // Streams layers from registry and writes to blobs/sha256/ directory + // Automatically deduplicates shared layers across images + // Rate limits during layer download also fail immediately (no retries) + err = path.AppendImage(img, layout.WithAnnotations(map[string]string{ + "org.opencontainers.image.ref.name": layoutTag, + })) + if err != nil { + return fmt.Errorf("download and write image layers: %w", err) + } + + return nil +} + +// extractDigest gets the manifest digest from the OCI layout +func (c *ociClient) extractDigest(layoutTag string) (string, error) { + casEngine, err := dir.Open(c.cacheDir) + if err != nil { + return "", fmt.Errorf("open oci layout: %w", err) + } + defer casEngine.Close() + + engine := casext.NewEngine(casEngine) + + // Resolve the layout tag in the shared layout + descriptorPaths, err := engine.ResolveReference(context.Background(), layoutTag) + if err != nil { + return "", fmt.Errorf("resolve reference: %w", err) + } + + if len(descriptorPaths) == 0 { + return "", fmt.Errorf("no image found in oci layout") + } + + // Get the manifest descriptor's digest + digest := descriptorPaths[0].Descriptor().Digest.String() + return digest, nil +} + +// extractOCIMetadata reads metadata from OCI layout config.json +func (c *ociClient) extractOCIMetadata(layoutTag string) (*containerMetadata, error) { + // Open the shared OCI layout + casEngine, err := dir.Open(c.cacheDir) + if err != nil { + return nil, fmt.Errorf("open oci layout: %w", err) + } + defer casEngine.Close() + + engine := casext.NewEngine(casEngine) + + // Resolve the layout tag in the shared layout + descriptorPaths, err := engine.ResolveReference(context.Background(), layoutTag) + if err != nil { + return nil, fmt.Errorf("resolve reference: %w", err) + } + + if len(descriptorPaths) == 0 { + return nil, fmt.Errorf("no image found in oci layout") + } + + // Get the manifest + manifestBlob, err := engine.FromDescriptor(context.Background(), descriptorPaths[0].Descriptor()) + if err != nil { + return nil, fmt.Errorf("get manifest: %w", err) + } + + // casext automatically parses manifests, so Data is already a v1.Manifest + manifest, ok := manifestBlob.Data.(v1.Manifest) + if !ok { + return nil, fmt.Errorf("manifest data is not v1.Manifest (got %T)", manifestBlob.Data) + } + + // Get the config blob + configBlob, err := engine.FromDescriptor(context.Background(), manifest.Config) + if err != nil { + return nil, fmt.Errorf("get config: %w", err) + } + + // casext automatically parses config, so Data is already a v1.Image + config, ok := configBlob.Data.(v1.Image) + if !ok { + return nil, fmt.Errorf("config data is not v1.Image (got %T)", configBlob.Data) + } + + // Extract metadata + meta := &containerMetadata{ + Entrypoint: config.Config.Entrypoint, + Cmd: config.Config.Cmd, + Env: make(map[string]string), + WorkingDir: config.Config.WorkingDir, + } + + // Parse environment variables + for _, env := range config.Config.Env { + for i := 0; i < len(env); i++ { + if env[i] == '=' { + key := env[:i] + val := env[i+1:] + meta.Env[key] = val + break + } + } + } + + return meta, nil +} + +// unpackLayers unpacks all OCI layers to a target directory using umoci +func (c *ociClient) unpackLayers(ctx context.Context, imageRef, targetDir string) error { + // Open the shared OCI layout + casEngine, err := dir.Open(c.cacheDir) + if err != nil { + return fmt.Errorf("open oci layout: %w", err) + } + defer casEngine.Close() + + engine := casext.NewEngine(casEngine) + + // Resolve the image reference (tag) in the shared layout + descriptorPaths, err := engine.ResolveReference(context.Background(), imageRef) + if err != nil { + return fmt.Errorf("resolve reference: %w", err) + } + + if len(descriptorPaths) == 0 { + return fmt.Errorf("no image found") + } + + // Get the manifest blob + manifestBlob, err := engine.FromDescriptor(context.Background(), descriptorPaths[0].Descriptor()) + if err != nil { + return fmt.Errorf("get manifest: %w", err) + } + + // casext automatically parses manifests + manifest, ok := manifestBlob.Data.(v1.Manifest) + if !ok { + return fmt.Errorf("manifest data is not v1.Manifest (got %T)", manifestBlob.Data) + } + + // Pre-create target directory (umoci needs it to exist) + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("create target dir: %w", err) + } + + // Unpack layers using umoci's layer package with rootless mode + // Map container UIDs to current user's UID (identity mapping) + uid := uint32(os.Getuid()) + gid := uint32(os.Getgid()) + + unpackOpts := &layer.UnpackOptions{ + OnDiskFormat: layer.DirRootfs{ + MapOptions: layer.MapOptions{ + Rootless: true, // Don't fail on chown errors + UIDMappings: []rspec.LinuxIDMapping{ + {HostID: uid, ContainerID: 0, Size: 1}, // Map container root to current user + }, + GIDMappings: []rspec.LinuxIDMapping{ + {HostID: gid, ContainerID: 0, Size: 1}, // Map container root group to current user group + }, + }, + }, + } + + err = layer.UnpackRootfs(context.Background(), casEngine, targetDir, manifest, unpackOpts) + if err != nil { + return fmt.Errorf("unpack rootfs: %w", err) + } + + return nil +} + +type containerMetadata struct { + Entrypoint []string + Cmd []string + Env map[string]string + WorkingDir string +} + diff --git a/lib/images/queue.go b/lib/images/queue.go new file mode 100644 index 00000000..115d267c --- /dev/null +++ b/lib/images/queue.go @@ -0,0 +1,115 @@ +package images + +import "sync" + +type QueuedBuild struct { + ImageName string + Request CreateImageRequest + StartFn func() +} + +// BuildQueue manages concurrent image builds with a configurable limit +type BuildQueue struct { + maxConcurrent int + active map[string]bool + pending []QueuedBuild + mu sync.Mutex +} + +func NewBuildQueue(maxConcurrent int) *BuildQueue { + if maxConcurrent < 1 { + maxConcurrent = 1 + } + return &BuildQueue{ + maxConcurrent: maxConcurrent, + active: make(map[string]bool), + pending: make([]QueuedBuild, 0), + } +} + +// Enqueue adds a build to the queue. Returns queue position (0 if started immediately, >0 if queued). +// If the image is already building or queued, returns its current position without re-enqueueing. +func (q *BuildQueue) Enqueue(imageName string, req CreateImageRequest, startFn func()) int { + q.mu.Lock() + defer q.mu.Unlock() + + // Check if already building (position 0, actively running) + if q.active[imageName] { + return 0 + } + + // Check if already in pending queue + for i, build := range q.pending { + if build.ImageName == imageName { + return i + 1 // Return existing queue position + } + } + + // Wrap the function to auto-complete + wrappedFn := func() { + defer q.MarkComplete(imageName) + startFn() + } + + build := QueuedBuild{ + ImageName: imageName, + Request: req, + StartFn: wrappedFn, + } + + if len(q.active) < q.maxConcurrent { + q.active[imageName] = true + go wrappedFn() + return 0 + } + + q.pending = append(q.pending, build) + return len(q.pending) +} + +func (q *BuildQueue) MarkComplete(imageName string) { + q.mu.Lock() + defer q.mu.Unlock() + + delete(q.active, imageName) + + if len(q.pending) > 0 && len(q.active) < q.maxConcurrent { + next := q.pending[0] + q.pending = q.pending[1:] + q.active[next.ImageName] = true + go next.StartFn() + } +} + +func (q *BuildQueue) GetPosition(imageName string) *int { + q.mu.Lock() + defer q.mu.Unlock() + + if q.active[imageName] { + return nil + } + + for i, build := range q.pending { + if build.ImageName == imageName { + pos := i + 1 + return &pos + } + } + + return nil +} + +// ActiveCount returns number of actively building images +func (q *BuildQueue) ActiveCount() int { + q.mu.Lock() + defer q.mu.Unlock() + return len(q.active) +} + +// PendingCount returns number of queued builds +func (q *BuildQueue) PendingCount() int { + q.mu.Lock() + defer q.mu.Unlock() + return len(q.pending) +} + diff --git a/lib/images/reference.go b/lib/images/reference.go new file mode 100644 index 00000000..8dd23ff2 --- /dev/null +++ b/lib/images/reference.go @@ -0,0 +1,160 @@ +package images + +import ( + "context" + "strings" + + "github.com/distribution/reference" +) + +// NormalizedRef is a validated and normalized OCI image reference. +// It can be either a tagged reference (e.g., "docker.io/library/alpine:latest") +// or a digest reference (e.g., "docker.io/library/alpine@sha256:abc123..."). +type NormalizedRef struct { + raw string + repository string + tag string // empty if digest ref + digest string // empty if tag ref + isDigest bool +} + +// ParseNormalizedRef validates and normalizes a user-provided image reference. +// Examples: +// - "alpine" -> "docker.io/library/alpine:latest" +// - "alpine:3.18" -> "docker.io/library/alpine:3.18" +// - "alpine@sha256:abc..." -> "docker.io/library/alpine@sha256:abc..." +func ParseNormalizedRef(s string) (*NormalizedRef, error) { + named, err := reference.ParseNormalizedNamed(s) + if err != nil { + return nil, err + } + + ref := &NormalizedRef{} + + // Extract repository (always present) + ref.repository = reference.Domain(named) + "/" + reference.Path(named) + + // If it's canonical (has digest), extract digest + if canonical, ok := named.(reference.Canonical); ok { + ref.isDigest = true + ref.digest = canonical.Digest().String() + ref.raw = canonical.String() + return ref, nil + } + + // Otherwise it's a tagged reference - ensure tag (add :latest if missing) + tagged := reference.TagNameOnly(named) + if t, ok := tagged.(reference.Tagged); ok { + ref.tag = t.Tag() + } + ref.raw = tagged.String() + + return ref, nil +} + +// String returns the full normalized reference. +func (r *NormalizedRef) String() string { + return r.raw +} + +// IsDigest returns true if this reference contains a digest (@sha256:...). +func (r *NormalizedRef) IsDigest() bool { + return r.isDigest +} + +// Digest returns the digest if present (e.g., "sha256:abc123..."). +// Returns empty string if this is a tagged reference. +func (r *NormalizedRef) Digest() string { + return r.digest +} + +// Repository returns the repository path without tag or digest. +// Example: "docker.io/library/alpine" +func (r *NormalizedRef) Repository() string { + return r.repository +} + +// Tag returns the tag if this is a tagged reference (e.g., "latest"). +// Returns empty string if this is a digest reference. +func (r *NormalizedRef) Tag() string { + return r.tag +} + +// DigestHex returns just the hex portion of the digest (without "sha256:" prefix). +// Returns empty string if this is a tagged reference. +func (r *NormalizedRef) DigestHex() string { + if r.digest == "" { + return "" + } + + // Strip "sha256:" prefix + parts := strings.SplitN(r.digest, ":", 2) + if len(parts) != 2 { + return "" // Invalid format + } + + return parts[1] +} + +// ResolvedRef is a NormalizedRef that has been resolved to include the actual +// manifest digest from the registry. The digest is always present. +type ResolvedRef struct { + normalized *NormalizedRef + digest string // Always populated (e.g., "sha256:abc123...") +} + +// NewResolvedRef creates a ResolvedRef from a NormalizedRef and digest. +func NewResolvedRef(normalized *NormalizedRef, digest string) *ResolvedRef { + return &ResolvedRef{ + normalized: normalized, + digest: digest, + } +} + +// String returns the full normalized reference (the original user input format). +func (r *ResolvedRef) String() string { + return r.normalized.String() +} + +// Repository returns the repository path without tag or digest. +// Example: "docker.io/library/alpine" +func (r *ResolvedRef) Repository() string { + return r.normalized.Repository() +} + +// Tag returns the tag if this was originally a tagged reference (e.g., "latest"). +// Returns empty string if this was originally a digest reference. +func (r *ResolvedRef) Tag() string { + return r.normalized.Tag() +} + +// Digest returns the resolved manifest digest (e.g., "sha256:abc123..."). +// This is always populated after resolution. +func (r *ResolvedRef) Digest() string { + return r.digest +} + +// DigestHex returns just the hex portion of the digest (without "sha256:" prefix). +func (r *ResolvedRef) DigestHex() string { + // Strip "sha256:" prefix + parts := strings.SplitN(r.digest, ":", 2) + if len(parts) != 2 { + return "" // Invalid format + } + return parts[1] +} + +// Resolve inspects the manifest to get the digest and returns a ResolvedRef. +// This requires an ociClient interface for manifest inspection. +type ManifestInspector interface { + inspectManifest(ctx context.Context, imageRef string) (string, error) +} + +// Resolve returns a ResolvedRef by inspecting the manifest to get the authoritative digest. +func (r *NormalizedRef) Resolve(ctx context.Context, inspector ManifestInspector) (*ResolvedRef, error) { + digest, err := inspector.inspectManifest(ctx, r.String()) + if err != nil { + return nil, err + } + return NewResolvedRef(r, digest), nil +} diff --git a/lib/images/storage.go b/lib/images/storage.go new file mode 100644 index 00000000..10ee921c --- /dev/null +++ b/lib/images/storage.go @@ -0,0 +1,273 @@ +package images + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type imageMetadata struct { + Name string `json:"name"` // Normalized ref (tag or digest) + Digest string `json:"digest"` // Always present: sha256:... + Status string `json:"status"` + Error *string `json:"error,omitempty"` + Request *CreateImageRequest `json:"request,omitempty"` + SizeBytes int64 `json:"size_bytes"` + Entrypoint []string `json:"entrypoint,omitempty"` + Cmd []string `json:"cmd,omitempty"` + Env map[string]string `json:"env,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func (m *imageMetadata) toImage() *Image { + img := &Image{ + Name: m.Name, + Digest: m.Digest, + Status: m.Status, + Error: m.Error, + CreatedAt: m.CreatedAt, + } + + if m.Status == StatusReady && m.SizeBytes > 0 { + sizeBytes := m.SizeBytes + img.SizeBytes = &sizeBytes + } + + if len(m.Entrypoint) > 0 { + img.Entrypoint = m.Entrypoint + } + if len(m.Cmd) > 0 { + img.Cmd = m.Cmd + } + if len(m.Env) > 0 { + img.Env = m.Env + } + if m.WorkingDir != "" { + img.WorkingDir = m.WorkingDir + } + + return img +} + +// digestDir returns the directory for a specific digest +// e.g., /var/lib/hypeman/images/docker.io/library/alpine/abc123def456... +func digestDir(dataDir, repository, digestHex string) string { + return filepath.Join(dataDir, "images", repository, digestHex) +} + +// digestPath returns the path to the rootfs.erofs file for a digest +func digestPath(dataDir, repository, digestHex string) string { + return filepath.Join(digestDir(dataDir, repository, digestHex), "rootfs.erofs") +} + +// metadataPath returns the path to metadata.json for a digest +func metadataPath(dataDir, repository, digestHex string) string { + return filepath.Join(digestDir(dataDir, repository, digestHex), "metadata.json") +} + +// tagSymlinkPath returns the path to a tag symlink +// e.g., /var/lib/hypeman/images/docker.io/library/alpine/latest +func tagSymlinkPath(dataDir, repository, tag string) string { + return filepath.Join(dataDir, "images", repository, tag) +} + +// writeMetadata writes metadata for a digest +func writeMetadata(dataDir, repository, digestHex string, meta *imageMetadata) error { + dir := digestDir(dataDir, repository, digestHex) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create digest directory: %w", err) + } + + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("marshal metadata: %w", err) + } + + tempPath := metadataPath(dataDir, repository, digestHex) + ".tmp" + if err := os.WriteFile(tempPath, data, 0644); err != nil { + return fmt.Errorf("write temp metadata: %w", err) + } + + finalPath := metadataPath(dataDir, repository, digestHex) + if err := os.Rename(tempPath, finalPath); err != nil { + os.Remove(tempPath) + return fmt.Errorf("rename metadata: %w", err) + } + + return nil +} + +// readMetadata reads metadata for a digest +func readMetadata(dataDir, repository, digestHex string) (*imageMetadata, error) { + path := metadataPath(dataDir, repository, digestHex) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("read metadata: %w", err) + } + + var meta imageMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("unmarshal metadata: %w", err) + } + + if meta.Status == StatusReady { + diskPath := digestPath(dataDir, repository, digestHex) + if _, err := os.Stat(diskPath); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("disk image missing: %s", diskPath) + } + return nil, fmt.Errorf("stat disk image: %w", err) + } + } + + return &meta, nil +} + +// createTagSymlink creates or updates a tag symlink to point to a digest +// Only creates the symlink if the digest dir exists and build is ready +func createTagSymlink(dataDir, repository, tag, digestHex string) error { + linkPath := tagSymlinkPath(dataDir, repository, tag) + targetPath := digestHex // Relative path (just the digest hex) + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(linkPath), 0755); err != nil { + return fmt.Errorf("create parent directory: %w", err) + } + + // Remove existing symlink if present + os.Remove(linkPath) + + // Create new symlink + if err := os.Symlink(targetPath, linkPath); err != nil { + return fmt.Errorf("create symlink: %w", err) + } + + return nil +} + +// resolveTag follows a tag symlink to get the digest hex +func resolveTag(dataDir, repository, tag string) (string, error) { + linkPath := tagSymlinkPath(dataDir, repository, tag) + + // Read the symlink + target, err := os.Readlink(linkPath) + if err != nil { + if os.IsNotExist(err) { + return "", ErrNotFound + } + return "", fmt.Errorf("read symlink: %w", err) + } + + // Validate it's just a digest hex (not an absolute path) + if filepath.IsAbs(target) || strings.Contains(target, "/") { + return "", fmt.Errorf("invalid symlink target: %s", target) + } + + return target, nil +} + +// listTags returns all tags for a repository +func listTags(dataDir, repository string) ([]string, error) { + repoDir := filepath.Join(dataDir, "images", repository) + + entries, err := os.ReadDir(repoDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read repository directory: %w", err) + } + + var tags []string + for _, entry := range entries { + // Check if it's a symlink + info, err := os.Lstat(filepath.Join(repoDir, entry.Name())) + if err != nil { + continue + } + + if info.Mode()&os.ModeSymlink != 0 { + tags = append(tags, entry.Name()) + } + } + + return tags, nil +} + +// listAllTags returns all tags across all repositories +func listAllTags(dataDir string) ([]*imageMetadata, error) { + imagesDir := filepath.Join(dataDir, "images") + var metas []*imageMetadata + + // Walk the images directory to find all repositories + err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + + // Check if this is a symlink (tag) + if info.Mode()&os.ModeSymlink != 0 { + // Read the symlink to get digest hex + digestHex, err := os.Readlink(path) + if err != nil { + return nil // Skip invalid symlinks + } + + // Get repository from path + relPath, err := filepath.Rel(imagesDir, filepath.Dir(path)) + if err != nil { + return nil + } + + // Read metadata for this digest + meta, err := readMetadata(dataDir, relPath, digestHex) + if err != nil { + return nil // Skip if metadata can't be read + } + + metas = append(metas, meta) + } + + return nil + }) + + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("walk images directory: %w", err) + } + + return metas, nil +} + +// digestExists checks if a digest directory exists +func digestExists(dataDir, repository, digestHex string) bool { + dir := digestDir(dataDir, repository, digestHex) + _, err := os.Stat(dir) + return err == nil +} + +// deleteTag removes a tag symlink (does not delete the digest directory) +func deleteTag(dataDir, repository, tag string) error { + linkPath := tagSymlinkPath(dataDir, repository, tag) + + // Check if symlink exists + if _, err := os.Lstat(linkPath); err != nil { + if os.IsNotExist(err) { + return ErrNotFound + } + return fmt.Errorf("stat symlink: %w", err) + } + + // Remove symlink + if err := os.Remove(linkPath); err != nil { + return fmt.Errorf("remove symlink: %w", err) + } + + return nil +} diff --git a/lib/images/types.go b/lib/images/types.go new file mode 100644 index 00000000..6b8a99a7 --- /dev/null +++ b/lib/images/types.go @@ -0,0 +1,24 @@ +package images + +import "time" + +// Image represents a container image converted to bootable disk +type Image struct { + Name string // Normalized ref (e.g., docker.io/library/alpine:latest) + Digest string // Resolved manifest digest (sha256:...) + Status string + QueuePosition *int + Error *string + SizeBytes *int64 + Entrypoint []string + Cmd []string + Env map[string]string + WorkingDir string + CreatedAt time.Time +} + +// CreateImageRequest represents a request to create an image +type CreateImageRequest struct { + Name string +} + diff --git a/lib/images/validation_test.go b/lib/images/validation_test.go new file mode 100644 index 00000000..891c3cf9 --- /dev/null +++ b/lib/images/validation_test.go @@ -0,0 +1,99 @@ +package images + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseNormalizedRef(t *testing.T) { + tests := []struct { + input string + expected string + wantErr bool + }{ + // Valid images with full reference + {"docker.io/library/alpine:latest", "docker.io/library/alpine:latest", false}, + {"ghcr.io/myorg/myapp:v1.0.0", "ghcr.io/myorg/myapp:v1.0.0", false}, + + // Shorthand (gets expanded) + {"alpine", "docker.io/library/alpine:latest", false}, + {"alpine:3.18", "docker.io/library/alpine:3.18", false}, + {"nginx", "docker.io/library/nginx:latest", false}, + {"nginx:alpine", "docker.io/library/nginx:alpine", false}, + + // Without tag (gets :latest added) + {"docker.io/library/alpine", "docker.io/library/alpine:latest", false}, + {"ubuntu", "docker.io/library/ubuntu:latest", false}, + + // Digest references (must be valid 64-char hex SHA256) + {"alpine@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "docker.io/library/alpine@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", false}, + {"docker.io/library/alpine@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", "docker.io/library/alpine@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", false}, + + // Invalid + {"", "", true}, + {"invalid::", "", true}, + {"has spaces", "", true}, + {"UPPERCASE", "", true}, // Repository names must be lowercase + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result, err := ParseNormalizedRef(tt.input) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, result.String()) + } + }) + } +} + +func TestNormalizedRefMethods(t *testing.T) { + t.Run("TaggedReference", func(t *testing.T) { + ref, err := ParseNormalizedRef("alpine:3.18") + require.NoError(t, err) + + require.False(t, ref.IsDigest()) + + repo := ref.Repository() + require.Equal(t, "docker.io/library/alpine", repo) + + tag := ref.Tag() + require.Equal(t, "3.18", tag) + + digest := ref.Digest() + require.Equal(t, "", digest) + + digestHex := ref.DigestHex() + require.Equal(t, "", digestHex) + }) + + t.Run("DigestReference", func(t *testing.T) { + ref, err := ParseNormalizedRef("alpine@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + require.NoError(t, err) + + require.True(t, ref.IsDigest()) + + repo := ref.Repository() + require.Equal(t, "docker.io/library/alpine", repo) + + tag := ref.Tag() + require.Equal(t, "", tag) + + digest := ref.Digest() + require.Equal(t, "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", digest) + + digestHex := ref.DigestHex() + require.Equal(t, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", digestHex) + }) + + t.Run("DefaultTag", func(t *testing.T) { + ref, err := ParseNormalizedRef("alpine") + require.NoError(t, err) + + tag := ref.Tag() + require.Equal(t, "latest", tag) + }) +} diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 58978307..bfc6abf2 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -3,102 +3,62 @@ package instances import ( "context" "fmt" - - "github.com/onkernel/hypeman/lib/oapi" ) -// Manager handles instance lifecycle operations type Manager interface { - ListInstances(ctx context.Context) ([]oapi.Instance, error) - CreateInstance(ctx context.Context, req oapi.CreateInstanceRequest) (*oapi.Instance, error) - GetInstance(ctx context.Context, id string) (*oapi.Instance, error) + ListInstances(ctx context.Context) ([]Instance, error) + CreateInstance(ctx context.Context, req CreateInstanceRequest) (*Instance, error) + GetInstance(ctx context.Context, id string) (*Instance, error) DeleteInstance(ctx context.Context, id string) error - StandbyInstance(ctx context.Context, id string) (*oapi.Instance, error) - RestoreInstance(ctx context.Context, id string) (*oapi.Instance, error) + StandbyInstance(ctx context.Context, id string) (*Instance, error) + RestoreInstance(ctx context.Context, id string) (*Instance, error) GetInstanceLogs(ctx context.Context, id string, follow bool, tail int) (string, error) - AttachVolume(ctx context.Context, id string, volumeId string, req oapi.AttachVolumeRequest) (*oapi.Instance, error) - DetachVolume(ctx context.Context, id string, volumeId string) (*oapi.Instance, error) + AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error) + DetachVolume(ctx context.Context, id string, volumeId string) (*Instance, error) } type manager struct { dataDir string } -// NewManager creates a new instance manager func NewManager(dataDir string) Manager { return &manager{ dataDir: dataDir, } } -func (m *manager) ListInstances(ctx context.Context) ([]oapi.Instance, error) { - // TODO: implement - return []oapi.Instance{}, nil +func (m *manager) ListInstances(ctx context.Context) ([]Instance, error) { + return []Instance{}, nil } -func (m *manager) CreateInstance(ctx context.Context, req oapi.CreateInstanceRequest) (*oapi.Instance, error) { - // TODO: implement +func (m *manager) CreateInstance(ctx context.Context, req CreateInstanceRequest) (*Instance, error) { return nil, fmt.Errorf("instance creation not yet implemented") } -func (m *manager) GetInstance(ctx context.Context, id string) (*oapi.Instance, error) { - // TODO: implement actual logic - exists := false - if !exists { - return nil, ErrNotFound - } - return nil, fmt.Errorf("get instance not yet implemented") +func (m *manager) GetInstance(ctx context.Context, id string) (*Instance, error) { + return nil, ErrNotFound } func (m *manager) DeleteInstance(ctx context.Context, id string) error { - // TODO: implement actual logic - exists := false - if !exists { - return ErrNotFound - } - return fmt.Errorf("delete instance not yet implemented") + return ErrNotFound } -func (m *manager) StandbyInstance(ctx context.Context, id string) (*oapi.Instance, error) { - // TODO: implement actual logic - exists := false - if !exists { - return nil, ErrNotFound - } - // Check if instance is in correct state (e.g., Running) - validState := false - if !validState { - return nil, ErrInvalidState - } +func (m *manager) StandbyInstance(ctx context.Context, id string) (*Instance, error) { return nil, fmt.Errorf("standby instance not yet implemented") } -func (m *manager) RestoreInstance(ctx context.Context, id string) (*oapi.Instance, error) { - // TODO: implement actual logic - exists := false - if !exists { - return nil, ErrNotFound - } - // Check if instance is in Standby state - inStandby := false - if !inStandby { - return nil, ErrInvalidState - } +func (m *manager) RestoreInstance(ctx context.Context, id string) (*Instance, error) { return nil, fmt.Errorf("restore instance not yet implemented") } func (m *manager) GetInstanceLogs(ctx context.Context, id string, follow bool, tail int) (string, error) { - // TODO: implement return "", fmt.Errorf("get instance logs not yet implemented") } -func (m *manager) AttachVolume(ctx context.Context, id string, volumeId string, req oapi.AttachVolumeRequest) (*oapi.Instance, error) { - // TODO: implement +func (m *manager) AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error) { return nil, fmt.Errorf("attach volume not yet implemented") } -func (m *manager) DetachVolume(ctx context.Context, id string, volumeId string) (*oapi.Instance, error) { - // TODO: implement +func (m *manager) DetachVolume(ctx context.Context, id string, volumeId string) (*Instance, error) { return nil, fmt.Errorf("detach volume not yet implemented") } - diff --git a/lib/instances/types.go b/lib/instances/types.go new file mode 100644 index 00000000..8b118ca4 --- /dev/null +++ b/lib/instances/types.go @@ -0,0 +1,21 @@ +package instances + +import "time" + +type Instance struct { + Id string + Name string + Image string + CreatedAt time.Time +} + +type CreateInstanceRequest struct { + Id string + Name string + Image string +} + +type AttachVolumeRequest struct { + MountPath string +} + diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 4950de53..46ef4ad6 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -32,6 +32,15 @@ const ( Ok HealthStatus = "ok" ) +// Defines values for ImageStatus. +const ( + Converting ImageStatus = "converting" + Failed ImageStatus = "failed" + Pending ImageStatus = "pending" + Pulling ImageStatus = "pulling" + Ready ImageStatus = "ready" +) + // Defines values for InstanceState. const ( Created InstanceState = "Created" @@ -59,9 +68,6 @@ type AttachVolumeRequest struct { // CreateImageRequest defines model for CreateImageRequest. type CreateImageRequest struct { - // Id Optional custom identifier (auto-generated if not provided) - Id *string `json:"id,omitempty"` - // Name OCI image reference (e.g., docker.io/library/nginx:latest) Name string `json:"name"` } @@ -149,28 +155,37 @@ type Image struct { // CreatedAt Creation timestamp (RFC3339) CreatedAt time.Time `json:"created_at"` + // Digest Resolved manifest digest + Digest string `json:"digest"` + // Entrypoint Entrypoint from container metadata Entrypoint *[]string `json:"entrypoint"` // Env Environment variables from container metadata Env *map[string]string `json:"env,omitempty"` - // Id Unique identifier - Id string `json:"id"` + // Error Error message if status is failed + Error *string `json:"error"` - // Name OCI image reference + // Name Normalized OCI image reference (tag or digest) Name string `json:"name"` - // SizeBytes Disk size in bytes - SizeBytes *int64 `json:"size_bytes,omitempty"` + // QueuePosition Position in build queue (null if not queued) + QueuePosition *int `json:"queue_position"` - // Version Image tag or digest - Version *string `json:"version"` + // SizeBytes Disk size in bytes (null until ready) + SizeBytes *int64 `json:"size_bytes"` + + // Status Build status + Status ImageStatus `json:"status"` // WorkingDir Working directory from container metadata WorkingDir *string `json:"working_dir"` } +// ImageStatus Build status +type ImageStatus string + // Instance defines model for Instance. type Instance struct { // CreatedAt Creation timestamp (RFC3339) @@ -392,10 +407,10 @@ type ClientInterface interface { CreateImage(ctx context.Context, body CreateImageJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // DeleteImage request - DeleteImage(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) + DeleteImage(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*http.Response, error) // GetImage request - GetImage(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) + GetImage(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*http.Response, error) // ListInstances request ListInstances(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -491,8 +506,8 @@ func (c *Client) CreateImage(ctx context.Context, body CreateImageJSONRequestBod return c.Client.Do(req) } -func (c *Client) DeleteImage(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewDeleteImageRequest(c.Server, id) +func (c *Client) DeleteImage(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteImageRequest(c.Server, name) if err != nil { return nil, err } @@ -503,8 +518,8 @@ func (c *Client) DeleteImage(ctx context.Context, id string, reqEditors ...Reque return c.Client.Do(req) } -func (c *Client) GetImage(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewGetImageRequest(c.Server, id) +func (c *Client) GetImage(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetImageRequest(c.Server, name) if err != nil { return nil, err } @@ -802,12 +817,12 @@ func NewCreateImageRequestWithBody(server string, contentType string, body io.Re } // NewDeleteImageRequest generates requests for DeleteImage -func NewDeleteImageRequest(server string, id string) (*http.Request, error) { +func NewDeleteImageRequest(server string, name string) (*http.Request, error) { var err error var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) if err != nil { return nil, err } @@ -836,12 +851,12 @@ func NewDeleteImageRequest(server string, id string) (*http.Request, error) { } // NewGetImageRequest generates requests for GetImage -func NewGetImageRequest(server string, id string) (*http.Request, error) { +func NewGetImageRequest(server string, name string) (*http.Request, error) { var err error var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) if err != nil { return nil, err } @@ -1429,10 +1444,10 @@ type ClientWithResponsesInterface interface { CreateImageWithResponse(ctx context.Context, body CreateImageJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateImageResponse, error) // DeleteImageWithResponse request - DeleteImageWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteImageResponse, error) + DeleteImageWithResponse(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*DeleteImageResponse, error) // GetImageWithResponse request - GetImageWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetImageResponse, error) + GetImageWithResponse(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*GetImageResponse, error) // ListInstancesWithResponse request ListInstancesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListInstancesResponse, error) @@ -1529,9 +1544,10 @@ func (r ListImagesResponse) StatusCode() int { type CreateImageResponse struct { Body []byte HTTPResponse *http.Response - JSON201 *Image + JSON202 *Image JSON400 *Error JSON401 *Error + JSON404 *Error JSON500 *Error } @@ -1949,8 +1965,8 @@ func (c *ClientWithResponses) CreateImageWithResponse(ctx context.Context, body } // DeleteImageWithResponse request returning *DeleteImageResponse -func (c *ClientWithResponses) DeleteImageWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteImageResponse, error) { - rsp, err := c.DeleteImage(ctx, id, reqEditors...) +func (c *ClientWithResponses) DeleteImageWithResponse(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*DeleteImageResponse, error) { + rsp, err := c.DeleteImage(ctx, name, reqEditors...) if err != nil { return nil, err } @@ -1958,8 +1974,8 @@ func (c *ClientWithResponses) DeleteImageWithResponse(ctx context.Context, id st } // GetImageWithResponse request returning *GetImageResponse -func (c *ClientWithResponses) GetImageWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetImageResponse, error) { - rsp, err := c.GetImage(ctx, id, reqEditors...) +func (c *ClientWithResponses) GetImageWithResponse(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*GetImageResponse, error) { + rsp, err := c.GetImage(ctx, name, reqEditors...) if err != nil { return nil, err } @@ -2187,12 +2203,12 @@ func ParseCreateImageResponse(rsp *http.Response) (*CreateImageResponse, error) } switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: var dest Image if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON201 = &dest + response.JSON202 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest Error @@ -2208,6 +2224,13 @@ func ParseCreateImageResponse(rsp *http.Response) (*CreateImageResponse, error) } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -2846,11 +2869,11 @@ type ServerInterface interface { // (POST /images) CreateImage(w http.ResponseWriter, r *http.Request) // Delete image - // (DELETE /images/{id}) - DeleteImage(w http.ResponseWriter, r *http.Request, id string) + // (DELETE /images/{name}) + DeleteImage(w http.ResponseWriter, r *http.Request, name string) // Get image details - // (GET /images/{id}) - GetImage(w http.ResponseWriter, r *http.Request, id string) + // (GET /images/{name}) + GetImage(w http.ResponseWriter, r *http.Request, name string) // List instances // (GET /instances) ListInstances(w http.ResponseWriter, r *http.Request) @@ -2915,14 +2938,14 @@ func (_ Unimplemented) CreateImage(w http.ResponseWriter, r *http.Request) { } // Delete image -// (DELETE /images/{id}) -func (_ Unimplemented) DeleteImage(w http.ResponseWriter, r *http.Request, id string) { +// (DELETE /images/{name}) +func (_ Unimplemented) DeleteImage(w http.ResponseWriter, r *http.Request, name string) { w.WriteHeader(http.StatusNotImplemented) } // Get image details -// (GET /images/{id}) -func (_ Unimplemented) GetImage(w http.ResponseWriter, r *http.Request, id string) { +// (GET /images/{name}) +func (_ Unimplemented) GetImage(w http.ResponseWriter, r *http.Request, name string) { w.WriteHeader(http.StatusNotImplemented) } @@ -3072,12 +3095,12 @@ func (siw *ServerInterfaceWrapper) DeleteImage(w http.ResponseWriter, r *http.Re var err error - // ------------- Path parameter "id" ------------- - var id string + // ------------- Path parameter "name" ------------- + var name string - err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + err = runtime.BindStyledParameterWithOptions("simple", "name", chi.URLParam(r, "name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "name", Err: err}) return } @@ -3088,7 +3111,7 @@ func (siw *ServerInterfaceWrapper) DeleteImage(w http.ResponseWriter, r *http.Re r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteImage(w, r, id) + siw.Handler.DeleteImage(w, r, name) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3103,12 +3126,12 @@ func (siw *ServerInterfaceWrapper) GetImage(w http.ResponseWriter, r *http.Reque var err error - // ------------- Path parameter "id" ------------- - var id string + // ------------- Path parameter "name" ------------- + var name string - err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + err = runtime.BindStyledParameterWithOptions("simple", "name", chi.URLParam(r, "name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "name", Err: err}) return } @@ -3119,7 +3142,7 @@ func (siw *ServerInterfaceWrapper) GetImage(w http.ResponseWriter, r *http.Reque r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetImage(w, r, id) + siw.Handler.GetImage(w, r, name) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3648,10 +3671,10 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Post(options.BaseURL+"/images", wrapper.CreateImage) }) r.Group(func(r chi.Router) { - r.Delete(options.BaseURL+"/images/{id}", wrapper.DeleteImage) + r.Delete(options.BaseURL+"/images/{name}", wrapper.DeleteImage) }) r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/images/{id}", wrapper.GetImage) + r.Get(options.BaseURL+"/images/{name}", wrapper.GetImage) }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/instances", wrapper.ListInstances) @@ -3754,11 +3777,11 @@ type CreateImageResponseObject interface { VisitCreateImageResponse(w http.ResponseWriter) error } -type CreateImage201JSONResponse Image +type CreateImage202JSONResponse Image -func (response CreateImage201JSONResponse) VisitCreateImageResponse(w http.ResponseWriter) error { +func (response CreateImage202JSONResponse) VisitCreateImageResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(201) + w.WriteHeader(202) return json.NewEncoder(w).Encode(response) } @@ -3781,6 +3804,15 @@ func (response CreateImage401JSONResponse) VisitCreateImageResponse(w http.Respo return json.NewEncoder(w).Encode(response) } +type CreateImage404JSONResponse Error + +func (response CreateImage404JSONResponse) VisitCreateImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + type CreateImage500JSONResponse Error func (response CreateImage500JSONResponse) VisitCreateImageResponse(w http.ResponseWriter) error { @@ -3791,7 +3823,7 @@ func (response CreateImage500JSONResponse) VisitCreateImageResponse(w http.Respo } type DeleteImageRequestObject struct { - Id string `json:"id"` + Name string `json:"name"` } type DeleteImageResponseObject interface { @@ -3825,7 +3857,7 @@ func (response DeleteImage500JSONResponse) VisitDeleteImageResponse(w http.Respo } type GetImageRequestObject struct { - Id string `json:"id"` + Name string `json:"name"` } type GetImageResponseObject interface { @@ -4400,10 +4432,10 @@ type StrictServerInterface interface { // (POST /images) CreateImage(ctx context.Context, request CreateImageRequestObject) (CreateImageResponseObject, error) // Delete image - // (DELETE /images/{id}) + // (DELETE /images/{name}) DeleteImage(ctx context.Context, request DeleteImageRequestObject) (DeleteImageResponseObject, error) // Get image details - // (GET /images/{id}) + // (GET /images/{name}) GetImage(ctx context.Context, request GetImageRequestObject) (GetImageResponseObject, error) // List instances // (GET /instances) @@ -4555,10 +4587,10 @@ func (sh *strictHandler) CreateImage(w http.ResponseWriter, r *http.Request) { } // DeleteImage operation middleware -func (sh *strictHandler) DeleteImage(w http.ResponseWriter, r *http.Request, id string) { +func (sh *strictHandler) DeleteImage(w http.ResponseWriter, r *http.Request, name string) { var request DeleteImageRequestObject - request.Id = id + request.Name = name handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.DeleteImage(ctx, request.(DeleteImageRequestObject)) @@ -4581,10 +4613,10 @@ func (sh *strictHandler) DeleteImage(w http.ResponseWriter, r *http.Request, id } // GetImage operation middleware -func (sh *strictHandler) GetImage(w http.ResponseWriter, r *http.Request, id string) { +func (sh *strictHandler) GetImage(w http.ResponseWriter, r *http.Request, name string) { var request GetImageRequestObject - request.Id = id + request.Name = name handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.GetImage(ctx, request.(GetImageRequestObject)) @@ -4963,57 +4995,61 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "4fp/AQAA//+BHkAF40wAAA==", + "H4sIAAAAAAAC/+xc/W8TOfP/Vyx/vycVKWleWjjI81MpcFSiULVcT3o4FDnrSeLDa29tbyBU/d8f+WU3", + "u1nnpdDm6B0SEtmsPeMZz8vHM06vcSLTTAoQRuPBNdbJFFLiPh4ZQ5LppeR5CudwlYM29utMyQyUYeAG", + "pTIXZpgRM7VPFHSiWGaYFHiAz4iZos9TUIBmjgrSU5lzikaA3DyguIXhC0kzDniAO6kwHUoMwS1s5pn9", + "ShvFxATftLACQqXgc89mTHJu8GBMuIbWEttTSxoRjeyUtptT0htJyYEIfOMoXuVMAcWDD1UxPpaD5egv", + "SIxlfqyAGDhJyWS1JgRJoamDd8cniNl5SMEYFIgE0B7sT/ZbiMrkE6h9JjucjRRR846YMPFlwIkBbR7V", + "VLN+bFNfS+K5ta0RTGhDRLJaNhAz+x+hlFm5CD+rvW5sVl0HL8WMKSlSEAbNiGJkxEFXxbvGb9+9eDl8", + "+fYSDyxnmiduagufvTt/jwf4oNvtWrqN9TPaVPnvgl3lgBgFYdiYgUJjqZCZAmJBTrSXKTljFCgazVFC", + "OAdV17cd2SajpNc/iBmj29EmZ2cgFcZ1kumknUyVTKE968WIppBKNR+m5MswHdXM/LD77EnDyskXluYp", + "8rPQZ2amaCpNxvMJYgKdPq8y9wQCRyYMTEBVWdbZ9br9w2V2z4mGgleDfL97+DRGPu4Sr/OUiLZ1TmsI", + "yA2qKiqdtz9L9YlLQttRRWVSmWFKsoyJiY6EHakMKl6jsZIpmkptkJFokntvYQZSN/P/FYzxAP9fZxEF", + "OyEEdiydU0+mYntEKTJ3zywFmZuhhkQKqmsaPHjS7S5r8L0f74xRJ4RD28j2V1ASaUiJMCyp+cSvfUui", + "qdNZkuV1Zv1lTm/zdAQKyTGaMWVywtHx2e814v0oZRejIwr1KUBbBRKXE7bVoJ/o84j1/qYal+IUswkh", + "GIT3sdVBa0NiikWGd5mPXijJtZFpNUTskdzI9gQEKGKAIjZGQhpUxIl6dJhJ3rZ5Km6ecav3y41auyPl", + "tR+jp9lXGE5GTZIX7KuNaWjCJmQ0N/WY2otYTywrLOjHVP1SKamayk0kjYh4lGWcJcQ+tXUGCRuzBIGl", + "gOwEtJeSZMoElL5f1+qI0KEK29mK5RRDGI+Y51GZlQKzMBLtWVdLc25YxsG/04+2tV0n+QtHKeb9TAhQ", + "QyjUcwtKKWgdzR5LcbGQpRziIgeFUT6ZWJVUVXfKtGZigordRWMGnA488tiIDtxuLha20g6CDFtawxv5", + "GVSbwwx41Qi8R9nFplIBKu3Eb9pSEp4RzuiQiSw38Yy5QpWvcmWmUFgCIiMbeC0C8BtWZeJztvX1scwF", + "jSqroY7XQLhHvHVNaENMHhBTnlrdyk9Wnwt28tPG7QhEYttwUuCOpQ1II8Hu+PSFT36JFIYwAQqlYEjA", + "1+WKPmCHJHELt61NUQKpFEiOx/+xKyhdpRnlcs6tneKBUTk0HSRxQZoOiYkszb6zFm1zqDYkzdDe+avj", + "g4ODZ/WQ0O/2H7e7vXbv8fted9C1//6LW3gsVWrpYkoMtC2RaMBgk5AZ6tzPQUs+A4pSItgYtEFhZJWz", + "npL+4ycDDwEpjA8fP9nf34+xAWHUPJNMRFi9LN9ttxUdj/PbC5r7evp9+3AP0H0bWa7x2dH71/ZQl2vV", + "4TIhvKNHTAwqz+Xj4oX74B9HTEQhfxlzl1bqQkyICDZ9ezdCTKMxYXzpqJnlnIfvB1YSAUlpkNIFmxV6", + "3ZTm31rT5OwrUBQ9+hkyQTaMO4v7vjNeC1/lkMMwk5p57k0k7N9YkDDKGafIzUB7VrgC4riv6gCnv1L8", + "Clx0sMHDjgbjF0x/QjrAEzcm8MyFYdwdzOc1jo8Pnjz9tfus1684NxPmySHeaill2F06tDiZw9tWGZMz", + "ENRnUGsG/lMixcx6hXtw67NxxhtOLYAX7xqbYc8sTEyGlEWs8w//ElGmIDH2ELWFD+EOybLNphhHdWVM", + "K8WvRORobgmH40h6uftQfnC7UH4/9YeGCsZXVMTABOdzdJUTbo8KFFGZEiaaSL5SM9h3AW6bKDIleqgF", + "yfRURrT7xxQcjiGoGIPgC9NGh5IG02VNo7qUUBZbrnltWS75EQshNZOTYswmuXIZvFYE+da6xwrqowda", + "87ijAkem2IwYGLIsws+/QydniFCqQNfOnrj3rL/fe/J0v9ft7ve62/iBNkStijEX9t03BJjHKwPMNssx", + "sEl/RcS8cIPdLJllK4WQ2a1k6G8IkhtliBaoYhWpJJg8CUXX29Sg7rPu5AtHQFExYndlp8ICts6aF4XB", + "LAXCouLsyA3+FG3kq1d0gC5PT1Ggjka5cWAsuAHaO+Yyp+j1PAM1Y1oqJIhhM3hkKZznQjAxsRQsvCWJ", + "fcPnSPnv108+I7n23O3czD2tn3ExzQ2Vn4Wbo6e5QfbJLdmKEBLSehLeMQborXRzwkpbSMjlzOaHE0FH", + "8+bw5Sy4lxCBRhZgayMV0Ed/igrOC5rGLRw0hlvYi49buJDKfvSrc58c48pOL9ypGi0bEMkVloc2SK+I", + "zUy4+oMbhy5Pq07xNOpjU7meoPQE7bA6sTi5TEkjE8lrlWNskqyiL/+U0ywi/5LHLFbXqsoe8xDvjU2V", + "keDdQyPX+M3JC3tOKcaugSYbw+Fdo9jus9sWJG6PvtYXmtf1Xn0T1L5bqb9qu/UbT7s/YFG7GssLJhuj", + "eCNjfGefm+miwV1z/PvodhdHhCbnde3vIusOYzYZdnWNTa46ECztxYJHa32H3RoEJLliZn5hk7jX+QiI", + "AnWUe5277O6EcF8vmE+NyfDNjSvKjyOx5DcQoFiCjs5O3LEpJYJMbJ68PEWcjSGZJxxQ7grojSTm+qrv", + "jk/a9jBAUQHS3fGRGacQOzolwtLHLTwDpT3f7n5v33WtZQaCZAwP8IH7qoWtGpyInWlZSZ6AMztrdC4W", + "nVC3dhNqzVazOpNCe930u11fehcm2CtZdF86f2lfDvKIaBNeChycCpec0aohcVblF+qxk87TlKi5ld19", + "i5IpJJ/cq47DT3qlQG+YNid+yHdKtBUU9AXzJv5rSGrXZZFrWP5NCx92e3emYd9Gi7D9XZDcTKViX4Fa", + "po/vcFtXMj0RBpQgHGlQM1ChKVJ1Qjz4UHe/Dx9vPlb33alroatM6sheV27NYB8YQJvnks7vTMTIvZyb", + "ehCy6eymYWn9O1tBMLCIkl0JZFRUID2qJ3oukkfeunaw0c8JRUVH9e+y6MPu4Q4seqmJ94A86SznHBFB", + "UahAL9oG1XjaubaQ5sYnNw7+qFn3thfu+8LbMqJICgaUditY2qPzN20QiaQWn3jVhcOvfetydIH4CihV", + "96hWRXHLEOBjw9sOVxUIvSg/zWQLM/G7WxhGayVa+I799/cSF9cSf+m/Co2oX/qvCM+YgF8Ojha3E+/H", + "WLq7Cs3FfZKfxrfR+H6DkOwXSnOhKRzSN6C9ctROAF/RDrkN5itX+BP2bQP7qupai/wWral7BH9Ld5e3", + "wn93t8ULe4spPBSxQvXhX4X7HopJeytyCMzB9EVDtR7jOteMboO/Fja/lIIj6dIVRu4aWRVGt3NwVTB+", + "kCnOteesEdAAtCp5ZCXW2uled3cbs3YOjx60+TiE1FBdM4B0uJzoddW+Qg1vpOvj37ldtRq3WyTn8jOy", + "60J72iggqS96Xly8LFH+VQ5qvuA5dnNwlc9ykbr5e6vV3WLOhP91gQKTK+GvRoG7ixvjHu4JR3j3Yu3q", + "LVzJwBfTgRkI0/YaqBtV5EKwnZBxwsT6kU3IKScosPjpWNvFZWeRpW95O3W2GXOv0Ah2/ZsoMj33A/7R", + "obvohv/NJnbYfXb/rI+lGHOWGNRe2IhdBRMWzgk6miOpqtcMHpLxB2NdSOYiY5Arav/Fu5X2H244/KPt", + "f7H3/3IPSKRSkBh/+ehhFcUrcKriynvuvtLiHlCrgOuXp6fxhBCujnWu/YeTTWe4xU/e7wl9RYgUS3sQ", + "XhYuB1AIN0p27mGyvOvwQAv5VnGFCC6gV8+a8ahd/VMMD8Eu777YF/tjFFuV+nbqFeU9qx/FK3adgcIa", + "CHc/k6np46E4qLe0QhIjlwqCldvKK1sel+V95ftveISgcIt2RyHBz8rwFs2OirLWtTrK0Hx/jY5viH13", + "t7mFla2MfD9bHD98i2NW7OEiim3Z1Lg/4LFVS6OEnLttaFz+OPmU6QeZSsOllVmZolZVvXdpYN3dBcVd", + "91AuH/C56Dcokm2lf+IIWIqxW0xvZEI4ojADLjP3414/Frdwrni4GD7o+D82MJXauB/H4JuPN/8LAAD/", + "/x8BGYD1TgAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/providers/providers.go b/lib/providers/providers.go index ea1fd2dc..d32f8f46 100644 --- a/lib/providers/providers.go +++ b/lib/providers/providers.go @@ -30,8 +30,8 @@ func ProvideConfig() *config.Config { } // ProvideImageManager provides the image manager -func ProvideImageManager(cfg *config.Config) images.Manager { - return images.NewManager(cfg.DataDir) +func ProvideImageManager(cfg *config.Config) (images.Manager, error) { + return images.NewManager(cfg.DataDir, cfg.MaxConcurrentBuilds) } // ProvideInstanceManager provides the instance manager @@ -43,4 +43,3 @@ func ProvideInstanceManager(cfg *config.Config) instances.Manager { func ProvideVolumeManager(cfg *config.Config) volumes.Manager { return volumes.NewManager(cfg.DataDir) } - diff --git a/lib/volumes/manager.go b/lib/volumes/manager.go index c7d64eec..bf22e7bd 100644 --- a/lib/volumes/manager.go +++ b/lib/volumes/manager.go @@ -3,15 +3,12 @@ package volumes import ( "context" "fmt" - - "github.com/onkernel/hypeman/lib/oapi" ) -// Manager handles volume lifecycle operations type Manager interface { - ListVolumes(ctx context.Context) ([]oapi.Volume, error) - CreateVolume(ctx context.Context, req oapi.CreateVolumeRequest) (*oapi.Volume, error) - GetVolume(ctx context.Context, id string) (*oapi.Volume, error) + ListVolumes(ctx context.Context) ([]Volume, error) + CreateVolume(ctx context.Context, req CreateVolumeRequest) (*Volume, error) + GetVolume(ctx context.Context, id string) (*Volume, error) DeleteVolume(ctx context.Context, id string) error } @@ -19,43 +16,24 @@ type manager struct { dataDir string } -// NewManager creates a new volume manager func NewManager(dataDir string) Manager { return &manager{ dataDir: dataDir, } } -func (m *manager) ListVolumes(ctx context.Context) ([]oapi.Volume, error) { - // TODO: implement - return []oapi.Volume{}, nil +func (m *manager) ListVolumes(ctx context.Context) ([]Volume, error) { + return []Volume{}, nil } -func (m *manager) CreateVolume(ctx context.Context, req oapi.CreateVolumeRequest) (*oapi.Volume, error) { - // TODO: implement +func (m *manager) CreateVolume(ctx context.Context, req CreateVolumeRequest) (*Volume, error) { return nil, fmt.Errorf("volume creation not yet implemented") } -func (m *manager) GetVolume(ctx context.Context, id string) (*oapi.Volume, error) { - // TODO: implement actual logic - exists := false - if !exists { - return nil, ErrNotFound - } - return nil, fmt.Errorf("get volume not yet implemented") +func (m *manager) GetVolume(ctx context.Context, id string) (*Volume, error) { + return nil, ErrNotFound } func (m *manager) DeleteVolume(ctx context.Context, id string) error { - // TODO: implement actual logic - exists := false - if !exists { - return ErrNotFound - } - // Check if volume is attached to any instance - inUse := false - if inUse { - return ErrInUse - } - return fmt.Errorf("delete volume not yet implemented") + return ErrNotFound } - diff --git a/lib/volumes/types.go b/lib/volumes/types.go new file mode 100644 index 00000000..971bf967 --- /dev/null +++ b/lib/volumes/types.go @@ -0,0 +1,17 @@ +package volumes + +import "time" + +type Volume struct { + Id string + Name string + SizeGb int + CreatedAt time.Time +} + +type CreateVolumeRequest struct { + Name string + SizeGb int + Id *string +} + diff --git a/openapi.yaml b/openapi.yaml index 37b24c75..28c08e06 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -233,33 +233,40 @@ components: type: string description: OCI image reference (e.g., docker.io/library/nginx:latest) example: docker.io/library/nginx:latest - id: - type: string - description: Optional custom identifier (auto-generated if not provided) - example: img-nginx-v1 Image: type: object - required: [id, name, created_at] + required: [name, digest, status, created_at] properties: - id: - type: string - description: Unique identifier - example: img-nginx-v1 name: type: string - description: OCI image reference + description: Normalized OCI image reference (tag or digest) example: docker.io/library/nginx:latest - version: + digest: + type: string + description: Resolved manifest digest + example: sha256:abc123def456... + status: + type: string + enum: [pending, pulling, converting, ready, failed] + description: Build status + example: ready + queue_position: + type: integer + description: Position in build queue (null if not queued) + example: 2 + nullable: true + error: type: string - description: Image tag or digest - example: latest + description: Error message if status is failed + example: "pull failed: connection timeout" nullable: true size_bytes: type: integer format: int64 - description: Disk size in bytes + description: Disk size in bytes (null until ready) example: 536870912 + nullable: true entrypoint: type: array items: @@ -415,8 +422,8 @@ paths: schema: $ref: "#/components/schemas/CreateImageRequest" responses: - 201: - description: Image created + 202: + description: Image build started (async) content: application/json: schema: @@ -427,6 +434,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + 404: + description: Image not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" 401: description: Unauthorized content: @@ -440,18 +453,19 @@ paths: schema: $ref: "#/components/schemas/Error" - /images/{id}: + /images/{name}: get: summary: Get image details operationId: getImage security: - bearerAuth: [] parameters: - - name: id + - name: name in: path required: true schema: type: string + description: URL-encoded image name (e.g. docker.io%2Flibrary%2Falpine%3Alatest) responses: 200: description: Image details @@ -477,11 +491,12 @@ paths: security: - bearerAuth: [] parameters: - - name: id + - name: name in: path required: true schema: type: string + description: URL-encoded image name responses: 204: description: Image deleted