Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ hypeman rm --force --all

### Compose

`hypeman compose` applies a small declarative workload file for images, instances, restart/health settings, and ingresses. See [lib/compose/README.md](lib/compose/README.md#compose).
`hypeman compose` applies a small declarative workload file for images or Dockerfiles, instances, restart/health settings, and ingresses. See [lib/compose/README.md](lib/compose/README.md#compose).

More ingress features:
- Automatic certs
Expand Down
30 changes: 26 additions & 4 deletions lib/compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Compose

`hypeman compose` is a lightweight way to declare a small workload for Hypeman.
`hypeman compose` is a lightweight way to declare a small workload for Hypeman from images or Dockerfiles.


```yaml
Expand Down Expand Up @@ -69,9 +69,10 @@ All compose commands honor global output flags such as `--format json`, `--forma

`up` applies the plan in order:

1. ensure referenced images exist and are ready
2. create or replace instances
3. create or replace ingresses
1. build Dockerfile services whose generated images are missing
2. ensure referenced images exist and are ready
3. create or replace instances
4. create or replace ingresses

`down` deletes only instances and ingresses tagged as owned by the compose file. Images are left in place because they can be shared by normal `hypeman run` usage or other compose files.

Expand All @@ -98,6 +99,27 @@ env:

File paths are resolved relative to the compose file. Missing files or environment variables fail before any resources are applied.

### Dockerfile Services

A service can use `dockerfile` instead of `image`:

```yaml
services:
worker:
dockerfile: ./Dockerfile
cmd: ["./worker"]
env:
CONFIG: ${file:worker.yaml}
restart:
policy: on_failure
```

The Dockerfile path is resolved relative to the compose file. The build context is the directory containing that Dockerfile. `compose up` creates a source archive, starts a Hypeman build, waits for the generated image to become ready, then creates the instance from that image.

Compose generates the build image name from the compose name, service name, Dockerfile, and build context hash. Re-running the same file reuses the existing image; changing the Dockerfile or context produces a new image name and makes the managed instance require replacement.

`image` and `dockerfile` are mutually exclusive for now. Use `image` for off-the-shelf images and `dockerfile` for Hypeman-built images.

### OTel Collector Example

The OTel collector can run from the upstream collector image without rebuilding it. Put the collector config in `otelcol.yaml`, reference it with `${file:otelcol.yaml}`, and pass `--config=env:OTELCOL_CONFIG` as the service command. Restart policy and healthcheck settings are applied to the instance create request, while ingress exposes only the collector port you choose.
270 changes: 270 additions & 0 deletions lib/compose/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
package compose

import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"sort"
"time"

"github.com/kernel/hypeman-go"
)

type desiredBuild struct {
Service string
Image string
Hash string
ImageRef string
DockerfilePath string
DockerfileContent string
Source []byte
}

func (r *Runner) desiredBuildForService(serviceName string, service composeServiceSpec) (desiredBuild, error) {
dockerfilePath := service.Dockerfile
if !filepath.IsAbs(dockerfilePath) {
dockerfilePath = filepath.Join(filepath.Dir(r.file), dockerfilePath)
}
dockerfilePath, err := filepath.Abs(dockerfilePath)
if err != nil {
return desiredBuild{}, fmt.Errorf("service %q dockerfile: %w", serviceName, err)
}
dockerfileContent, err := os.ReadFile(dockerfilePath)
if err != nil {
return desiredBuild{}, fmt.Errorf("service %q dockerfile: %w", serviceName, err)
}
source, err := createSourceTarball(filepath.Dir(dockerfilePath))
if err != nil {
return desiredBuild{}, fmt.Errorf("service %q build context: %w", serviceName, err)
}
hash := buildHash(source, dockerfileContent)
image := composeBuildImageName(r.spec.Name, serviceName, hash)
return desiredBuild{
Service: serviceName,
Image: image,
Hash: hash,
DockerfilePath: dockerfilePath,
DockerfileContent: string(dockerfileContent),
Source: source,
}, nil
}

func (r *Runner) planBuild(ctx context.Context, build desiredBuild) (Action, error) {
action := Action{
Type: "build",
Name: build.Image,
Service: build.Service,
buildInput: &build,
}

readyBuild, err := r.findReadyBuild(ctx, build)
if err != nil {
return Action{}, err
}
if readyBuild != nil {
action.Action = "unchanged"
action.Reason = "build already ready"
action.buildInput.ImageRef = runnableBuildImage(readyBuild)
return action, nil
}

_, err = r.client.Images.Get(ctx, url.PathEscape(build.Image), r.opts...)
if err == nil {
action.Action = "unchanged"
action.Reason = "image already exists"
action.buildInput.ImageRef = build.Image
return action, nil
}
if isHTTPNotFound(err) {
action.Action = "create"
action.Reason = "image missing"
return action, nil
}
return Action{}, fmt.Errorf("check build image %s: %w", build.Image, err)
}

func (r *Runner) findReadyBuild(ctx context.Context, build desiredBuild) (*hypeman.Build, error) {
builds, err := r.client.Builds.List(ctx, hypeman.BuildListParams{
Tags: composeTags(r.spec.Name, build.Service, composeResourceBuild, build.Hash),
}, r.opts...)
if err != nil {
return nil, fmt.Errorf("list builds for %s: %w", build.Image, err)
}
if builds == nil {
return nil, nil
}
var ready []hypeman.Build
for _, existing := range *builds {
if existing.Status == hypeman.BuildStatusReady && runnableBuildImage(&existing) != "" {
ready = append(ready, existing)
}
}
if len(ready) == 0 {
return nil, nil
}
sort.Slice(ready, func(i, j int) bool {
return ready[i].CreatedAt.After(ready[j].CreatedAt)
})
return &ready[0], nil
}

func (r *Runner) runBuild(ctx context.Context, build desiredBuild, verbose bool) (string, error) {
if verbose {
fmt.Fprintf(os.Stderr, "[build] image %s from %s\n", build.Image, build.DockerfilePath)
}
tags, err := json.Marshal(composeTags(r.spec.Name, build.Service, composeResourceBuild, build.Hash))
if err != nil {
return "", err
}
started, err := r.client.Builds.New(ctx, hypeman.BuildNewParams{
Source: bytes.NewReader(build.Source),
Dockerfile: hypeman.Opt(build.DockerfileContent),
Tags: hypeman.Opt(string(tags)),
}, r.opts...)
if err != nil {
return "", fmt.Errorf("start build %s: %w", build.Image, err)
}
if verbose {
fmt.Fprintf(os.Stderr, "[wait] build %s ready\n", started.ID)
}
readyBuild, err := r.waitBuildReady(ctx, started.ID)
if err != nil {
return "", err
}
return runnableBuildImage(readyBuild), nil
}

func (r *Runner) waitBuildReady(ctx context.Context, buildID string) (*hypeman.Build, error) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()

for {
build, err := r.client.Builds.Get(ctx, buildID, r.opts...)
if err != nil {
return nil, fmt.Errorf("check build %s: %w", buildID, err)
}
switch build.Status {
case hypeman.BuildStatusReady:
return build, nil
case hypeman.BuildStatusFailed:
if build.Error != "" {
return nil, fmt.Errorf("build %s failed: %s", buildID, build.Error)
}
return nil, fmt.Errorf("build %s failed", buildID)
case hypeman.BuildStatusCancelled:
return nil, fmt.Errorf("build %s was cancelled", buildID)
}

select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
}
}

func runnableBuildImage(build *hypeman.Build) string {
if build.ImageRef != "" {
return build.ImageRef
}
if build.ID != "" {
return fmt.Sprintf("docker.io/builds/%s:latest", build.ID)
}
return ""
}

func composeBuildImageName(composeName, serviceName, hash string) string {
return fmt.Sprintf("compose/%s/%s:%s", composeName, serviceName, hash)
}

func createSourceTarball(contextPath string) ([]byte, error) {
var buf bytes.Buffer
gzWriter := gzip.NewWriter(&buf)
tarWriter := tar.NewWriter(gzWriter)

err := filepath.Walk(contextPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(contextPath, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
base := filepath.Base(path)
if base == ".git" || base == "node_modules" || base == "__pycache__" ||
base == ".venv" || base == "venv" || base == "target" ||
base == ".docker" || base == ".dockerignore" {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}

header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(relPath)
header.ModTime = time.Unix(0, 0)
header.AccessTime = time.Unix(0, 0)
header.ChangeTime = time.Unix(0, 0)
header.Uid = 0
header.Gid = 0
header.Uname = ""
header.Gname = ""
if info.Mode()&os.ModeSymlink != 0 {
linkTarget, err := os.Readlink(path)
if err != nil {
return err
}
header.Linkname = linkTarget
}
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if info.Mode().IsRegular() {
file, err := os.Open(path)
if err != nil {
return err
}
_, copyErr := io.Copy(tarWriter, file)
closeErr := file.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
}
return nil
})
if err != nil {
return nil, err
}
if err := tarWriter.Close(); err != nil {
return nil, err
}
if err := gzWriter.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

func buildHash(source []byte, dockerfile []byte) string {
sum := sha256.New()
sum.Write(source)
sum.Write(dockerfile)
return hex.EncodeToString(sum.Sum(nil))[:12]
}
2 changes: 2 additions & 0 deletions lib/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const (

composeResourceInstance = "instance"
composeResourceIngress = "ingress"
composeResourceBuild = "build"
)

type Runner struct {
Expand Down Expand Up @@ -56,6 +57,7 @@ type Action struct {
ingressID string
instanceInput map[string]any
ingressInput hypeman.IngressNewParams
buildInput *desiredBuild
}

func NewRunner(file string, client hypeman.Client, opts ...option.RequestOption) (*Runner, error) {
Expand Down
Loading
Loading