Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
58eac6d
Export images to rootfs with docker
sjmiller609 Nov 6, 2025
521917d
docker client dependency injection
sjmiller609 Nov 6, 2025
1b7b6a3
Fix permissions during extraction
sjmiller609 Nov 6, 2025
e0110bd
Don't skip docker in tests
sjmiller609 Nov 6, 2025
817a85c
extraction test passing but seems too complicated
sjmiller609 Nov 6, 2025
19e4c64
Use umoci for image to rootfs conversion
sjmiller609 Nov 7, 2025
3cf8089
Update docs
sjmiller609 Nov 7, 2025
2dcfde5
Update README
sjmiller609 Nov 7, 2025
9c79b18
Move OCI cache dir
sjmiller609 Nov 7, 2025
db95987
Tweak disk settings
sjmiller609 Nov 7, 2025
4e13b7c
Async API
sjmiller609 Nov 7, 2025
c376477
Simplify async API: remove sse for now
sjmiller609 Nov 7, 2025
0ee7646
Avoid rate limit in CI
sjmiller609 Nov 7, 2025
a12d4ba
Add API level image test
sjmiller609 Nov 7, 2025
5c5e07d
Check queuing works
sjmiller609 Nov 7, 2025
8bf8a4f
Test failure on invalid tag
sjmiller609 Nov 7, 2025
d39fdca
Fix image filesystem layout
sjmiller609 Nov 7, 2025
5fbacba
image name validation
sjmiller609 Nov 7, 2025
bc8a7e5
Update README
sjmiller609 Nov 7, 2025
6cc60a6
Clean up error
sjmiller609 Nov 10, 2025
675e090
Use erofs
sjmiller609 Nov 10, 2025
e68a49d
API layer depends on domain
sjmiller609 Nov 10, 2025
a5737ab
Idempotency and race conditions
sjmiller609 Nov 10, 2025
3e8e835
Add erofs to ci
sjmiller609 Nov 10, 2025
3c9fc0d
OCI client internal to image manager
sjmiller609 Nov 10, 2025
5fda8e9
Disambiguate digests and tags
sjmiller609 Nov 10, 2025
37fac0b
WIP: figuring out error handling registry errors
sjmiller609 Nov 10, 2025
d8bab8a
Switch to more lightweight library for interacting with registry
sjmiller609 Nov 10, 2025
0c0f38a
Handle 404
sjmiller609 Nov 10, 2025
8126152
Use docker auth config file
sjmiller609 Nov 10, 2025
866f855
Add timeout on resolve
sjmiller609 Nov 10, 2025
4001e3a
Update README
sjmiller609 Nov 10, 2025
28e2589
Poll by digest
sjmiller609 Nov 10, 2025
3b09260
Tweak readme
sjmiller609 Nov 10, 2025
55ccb76
caching test
sjmiller609 Nov 10, 2025
325b5e9
More comprehensive basic test
sjmiller609 Nov 10, 2025
f310f98
404 error mapping
sjmiller609 Nov 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .air.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ 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)
$(AIR) -c .air.toml

# Run tests
test:
go test -v -timeout 30s ./...
go test -tags containers_image_openpgp -v -timeout 30s ./...

# Clean generated files and binaries
clean:
Expand Down
32 changes: 23 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions cmd/api/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand All @@ -27,4 +32,3 @@ func newTestService(t *testing.T) *ApiService {
func ctx() context.Context {
return context.Background()
}

73 changes: 55 additions & 18 deletions cmd/api/api/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,45 @@ 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{
Code: "internal_error",
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)
Expand All @@ -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",
Expand All @@ -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
}
Loading
Loading