From 7a18a75d65ac8729f9c565cd7091d92fa42a9b78 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 16 May 2026 03:57:09 +0000 Subject: [PATCH 1/2] Add compose Dockerfile builds --- README.md | 2 +- lib/compose/README.md | 30 ++++- lib/compose/build.go | 220 ++++++++++++++++++++++++++++++++++++ lib/compose/compose.go | 1 + lib/compose/compose_test.go | 53 ++++++++- lib/compose/desired.go | 21 +++- lib/compose/reconcile.go | 42 ++++--- lib/compose/spec.go | 8 +- 8 files changed, 350 insertions(+), 27 deletions(-) create mode 100644 lib/compose/build.go diff --git a/README.md b/README.md index 8d7c01a..1f6b5da 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/compose/README.md b/lib/compose/README.md index 79c9bb8..dace5c6 100644 --- a/lib/compose/README.md +++ b/lib/compose/README.md @@ -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 @@ -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. @@ -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. diff --git a/lib/compose/build.go b/lib/compose/build.go new file mode 100644 index 0000000..5d6a771 --- /dev/null +++ b/lib/compose/build.go @@ -0,0 +1,220 @@ +package compose + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/kernel/hypeman-go" +) + +type desiredBuild struct { + Service string + Image 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, + DockerfilePath: dockerfilePath, + DockerfileContent: string(dockerfileContent), + Source: source, + }, nil +} + +func (r *Runner) planBuild(ctx context.Context, build desiredBuild) (Action, error) { + _, err := r.client.Images.Get(ctx, url.PathEscape(build.Image), r.opts...) + action := Action{ + Type: "build", + Name: build.Image, + Service: build.Service, + buildInput: &build, + } + if err == nil { + action.Action = "unchanged" + action.Reason = "image already exists" + 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) runBuild(ctx context.Context, build desiredBuild, verbose bool) error { + if verbose { + fmt.Fprintf(os.Stderr, "[build] image %s from %s\n", build.Image, build.DockerfilePath) + } + started, err := r.client.Builds.New(ctx, hypeman.BuildNewParams{ + Source: bytes.NewReader(build.Source), + Dockerfile: hypeman.Opt(build.DockerfileContent), + ImageName: hypeman.Opt(build.Image), + }, 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) + } + if _, err := r.waitBuildReady(ctx, started.ID); err != nil { + return err + } + return r.waitBuiltImageReady(ctx, build.Image) +} + +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 (r *Runner) waitBuiltImageReady(ctx context.Context, image string) error { + img, err := r.client.Images.Get(ctx, url.PathEscape(image), r.opts...) + if err != nil { + return fmt.Errorf("built image %s unavailable: %w", image, err) + } + return waitForImageReady(ctx, &r.client, img) +} + +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] +} diff --git a/lib/compose/compose.go b/lib/compose/compose.go index 3f448c3..27ac42b 100644 --- a/lib/compose/compose.go +++ b/lib/compose/compose.go @@ -56,6 +56,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) { diff --git a/lib/compose/compose_test.go b/lib/compose/compose_test.go index 07bf508..2653c32 100644 --- a/lib/compose/compose_test.go +++ b/lib/compose/compose_test.go @@ -98,7 +98,7 @@ func TestDesiredResourcesUseDeterministicNamesAndTags(t *testing.T) { }, } - instances, ingresses, images, err := runner.desiredResources() + _, instances, ingresses, images, err := runner.desiredResources() require.NoError(t, err) require.Equal(t, []string{"otel/opentelemetry-collector-contrib:0.108.0"}, images) @@ -126,6 +126,57 @@ func TestValidateComposeSpecRejectsInvalidNames(t *testing.T) { require.EqualError(t, err, "compose name must contain only lowercase letters, digits, and dashes") } +func TestValidateComposeSpecRejectsImageAndDockerfile(t *testing.T) { + err := validateComposeSpec(&composeSpec{ + Version: 1, + Name: "worker-stack", + Services: map[string]composeServiceSpec{ + "worker": {Image: "alpine:latest", Dockerfile: "./Dockerfile"}, + }, + }) + + require.EqualError(t, err, `service "worker" cannot include both image and dockerfile`) +} + +func TestDesiredResourcesBuildsDockerfileService(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM alpine:latest\nCOPY worker /worker\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "worker"), []byte("echo ok\n"), 0644)) + + composePath := filepath.Join(dir, "hypeman.compose.yaml") + require.NoError(t, os.WriteFile(composePath, []byte(` +version: 1 +name: worker-stack +services: + worker: + dockerfile: ./Dockerfile + cmd: ["./worker"] +`), 0644)) + + spec, err := loadComposeSpec(composePath) + require.NoError(t, err) + + runner := Runner{file: composePath, spec: spec} + builds, instances, _, images, err := runner.desiredResources() + require.NoError(t, err) + + require.Empty(t, images) + require.Len(t, builds, 1) + require.Len(t, instances, 1) + assert.Equal(t, "worker", builds[0].Service) + assert.Regexp(t, `^compose/worker-stack/worker:[a-f0-9]{12}$`, builds[0].Image) + assert.Equal(t, builds[0].Image, instances[0].Input["image"]) + + again, _, _, _, err := runner.desiredResources() + require.NoError(t, err) + require.Equal(t, builds[0].Image, again[0].Image) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "worker"), []byte("echo changed\n"), 0644)) + changed, _, _, _, err := runner.desiredResources() + require.NoError(t, err) + require.NotEqual(t, builds[0].Image, changed[0].Image) +} + func TestConflictBlockers(t *testing.T) { blockers := conflictBlockers([]Action{ {Action: "create", Type: "image", Name: "alpine:latest"}, diff --git a/lib/compose/desired.go b/lib/compose/desired.go index 1280637..9f0ee8f 100644 --- a/lib/compose/desired.go +++ b/lib/compose/desired.go @@ -25,12 +25,14 @@ type desiredIngress struct { Input hypeman.IngressNewParams } -func (r *Runner) desiredResources() ([]desiredInstance, []desiredIngress, []string, error) { +func (r *Runner) desiredResources() ([]desiredBuild, []desiredInstance, []desiredIngress, []string, error) { serviceNames := make([]string, 0, len(r.spec.Services)) imageSet := map[string]struct{}{} for name, service := range r.spec.Services { serviceNames = append(serviceNames, name) - imageSet[service.Image] = struct{}{} + if service.Image != "" { + imageSet[service.Image] = struct{}{} + } } sort.Strings(serviceNames) @@ -40,15 +42,24 @@ func (r *Runner) desiredResources() ([]desiredInstance, []desiredIngress, []stri } sort.Strings(images) + var builds []desiredBuild instances := make([]desiredInstance, 0, len(serviceNames)) var ingresses []desiredIngress for _, serviceName := range serviceNames { service := r.spec.Services[serviceName] + if service.Dockerfile != "" { + build, err := r.desiredBuildForService(serviceName, service) + if err != nil { + return nil, nil, nil, nil, err + } + builds = append(builds, build) + service.Image = build.Image + } instanceName := composeInstanceName(r.spec.Name, serviceName) instanceInput := buildComposeInstanceInput(instanceName, service) instanceHash, err := shortHash(instanceInput) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } instanceInput["tags"] = composeTags(r.spec.Name, serviceName, composeResourceInstance, instanceHash) instances = append(instances, desiredInstance{ @@ -63,7 +74,7 @@ func (r *Runner) desiredResources() ([]desiredInstance, []desiredIngress, []stri ingressInput := buildComposeIngressInput(instanceName, ingressName, ingressSpec) ingressHash, err := shortHash(ingressInput) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } ingressInput.Tags = composeTags(r.spec.Name, serviceName, composeResourceIngress, ingressHash) ingresses = append(ingresses, desiredIngress{ @@ -74,7 +85,7 @@ func (r *Runner) desiredResources() ([]desiredInstance, []desiredIngress, []stri }) } } - return instances, ingresses, images, nil + return builds, instances, ingresses, images, nil } func buildComposeInstanceInput(instanceName string, service composeServiceSpec) map[string]any { diff --git a/lib/compose/reconcile.go b/lib/compose/reconcile.go index 8271dad..568bb6d 100644 --- a/lib/compose/reconcile.go +++ b/lib/compose/reconcile.go @@ -13,12 +13,19 @@ import ( ) func (r *Runner) Plan(ctx context.Context) (Plan, error) { - desiredInstances, desiredIngresses, images, err := r.desiredResources() + desiredBuilds, desiredInstances, desiredIngresses, images, err := r.desiredResources() if err != nil { return Plan{}, err } var actions []Action + for _, build := range desiredBuilds { + action, err := r.planBuild(ctx, build) + if err != nil { + return Plan{}, err + } + actions = append(actions, action) + } for _, image := range images { action, err := r.planImage(ctx, image) if err != nil { @@ -92,6 +99,11 @@ func (r *Runner) Up(ctx context.Context, opts UpOptions) (Plan, error) { if opts.Verbose { fmt.Fprintf(os.Stderr, "[skip] %s %s unchanged\n", action.Type, action.Name) } + if action.Type == "build" { + if err := r.waitBuiltImageReady(ctx, action.Name); err != nil { + return result, err + } + } if action.Type == "image" { if err := r.ensureImageReady(ctx, action.Name, opts.Verbose); err != nil { return result, err @@ -145,20 +157,17 @@ func (r *Runner) Down(ctx context.Context, verbose bool) (Plan, error) { Summary: summarizeComposeActions(actions), } if len(actions) == 0 { - _, desiredIngresses, _, err := r.desiredResources() - if err != nil { - return Plan{}, err - } - for _, ingress := range desiredIngresses { - result.Actions = append(result.Actions, Action{ - Action: "skip", - Type: "ingress", - Name: ingress.Name, - Service: ingress.Service, - Reason: "not found", - }) - } for serviceName := range r.spec.Services { + service := r.spec.Services[serviceName] + for i := range service.Ingress { + result.Actions = append(result.Actions, Action{ + Action: "skip", + Type: "ingress", + Name: composeIngressName(r.spec.Name, serviceName, i), + Service: serviceName, + Reason: "not found", + }) + } result.Actions = append(result.Actions, Action{ Action: "skip", Type: "instance", @@ -194,6 +203,11 @@ func (r *Runner) Down(ctx context.Context, verbose bool) (Plan, error) { func (r *Runner) applyCreate(ctx context.Context, action *Action, opts UpOptions) error { switch action.Type { + case "build": + if action.buildInput == nil { + return fmt.Errorf("build action %s missing build input", action.Name) + } + return r.runBuild(ctx, *action.buildInput, opts.Verbose) case "image": return r.ensureImageReady(ctx, action.Name, opts.Verbose) case "instance": diff --git a/lib/compose/spec.go b/lib/compose/spec.go index 84cb9c5..820c4be 100644 --- a/lib/compose/spec.go +++ b/lib/compose/spec.go @@ -18,6 +18,7 @@ type composeSpec struct { type composeServiceSpec struct { Image string `json:"image" yaml:"image"` + Dockerfile string `json:"dockerfile,omitempty" yaml:"dockerfile"` Entrypoint []string `json:"entrypoint,omitempty" yaml:"entrypoint"` Cmd []string `json:"cmd,omitempty" yaml:"cmd"` Env map[string]string `json:"env,omitempty" yaml:"env"` @@ -122,8 +123,11 @@ func validateComposeSpec(spec *composeSpec) error { if len(instanceName) > 63 { return fmt.Errorf("service %q produces instance name %q longer than 63 characters", name, instanceName) } - if service.Image == "" { - return fmt.Errorf("service %q image is required", name) + if service.Image == "" && service.Dockerfile == "" { + return fmt.Errorf("service %q image or dockerfile is required", name) + } + if service.Image != "" && service.Dockerfile != "" { + return fmt.Errorf("service %q cannot include both image and dockerfile", name) } for i, rule := range service.Ingress { if rule.Hostname == "" { From 1e4b654087acadb9f4d9f19df467a4fc37a242a2 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 16 May 2026 06:29:50 +0000 Subject: [PATCH 2/2] Use build outputs for compose Dockerfile services --- lib/compose/build.go | 74 +++++++++++++++++++++++++++++++------ lib/compose/compose.go | 1 + lib/compose/compose_test.go | 30 +++++++++++++++ lib/compose/desired.go | 20 ++++++++++ lib/compose/reconcile.go | 43 +++++++++++++++++++-- 5 files changed, 153 insertions(+), 15 deletions(-) diff --git a/lib/compose/build.go b/lib/compose/build.go index 5d6a771..eacab3c 100644 --- a/lib/compose/build.go +++ b/lib/compose/build.go @@ -7,11 +7,13 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "io" "net/url" "os" "path/filepath" + "sort" "time" "github.com/kernel/hypeman-go" @@ -20,6 +22,8 @@ import ( type desiredBuild struct { Service string Image string + Hash string + ImageRef string DockerfilePath string DockerfileContent string Source []byte @@ -47,6 +51,7 @@ func (r *Runner) desiredBuildForService(serviceName string, service composeServi return desiredBuild{ Service: serviceName, Image: image, + Hash: hash, DockerfilePath: dockerfilePath, DockerfileContent: string(dockerfileContent), Source: source, @@ -54,16 +59,29 @@ func (r *Runner) desiredBuildForService(serviceName string, service composeServi } func (r *Runner) planBuild(ctx context.Context, build desiredBuild) (Action, error) { - _, err := r.client.Images.Get(ctx, url.PathEscape(build.Image), r.opts...) 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) { @@ -74,25 +92,55 @@ func (r *Runner) planBuild(ctx context.Context, build desiredBuild) (Action, err return Action{}, fmt.Errorf("check build image %s: %w", build.Image, err) } -func (r *Runner) runBuild(ctx context.Context, build desiredBuild, verbose bool) error { +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), - ImageName: hypeman.Opt(build.Image), + Tags: hypeman.Opt(string(tags)), }, r.opts...) if err != nil { - return fmt.Errorf("start build %s: %w", build.Image, err) + return "", fmt.Errorf("start build %s: %w", build.Image, err) } if verbose { fmt.Fprintf(os.Stderr, "[wait] build %s ready\n", started.ID) } - if _, err := r.waitBuildReady(ctx, started.ID); err != nil { - return err + readyBuild, err := r.waitBuildReady(ctx, started.ID) + if err != nil { + return "", err } - return r.waitBuiltImageReady(ctx, build.Image) + return runnableBuildImage(readyBuild), nil } func (r *Runner) waitBuildReady(ctx context.Context, buildID string) (*hypeman.Build, error) { @@ -124,12 +172,14 @@ func (r *Runner) waitBuildReady(ctx context.Context, buildID string) (*hypeman.B } } -func (r *Runner) waitBuiltImageReady(ctx context.Context, image string) error { - img, err := r.client.Images.Get(ctx, url.PathEscape(image), r.opts...) - if err != nil { - return fmt.Errorf("built image %s unavailable: %w", image, err) +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 waitForImageReady(ctx, &r.client, img) + return "" } func composeBuildImageName(composeName, serviceName, hash string) string { diff --git a/lib/compose/compose.go b/lib/compose/compose.go index 27ac42b..eabc286 100644 --- a/lib/compose/compose.go +++ b/lib/compose/compose.go @@ -13,6 +13,7 @@ const ( composeResourceInstance = "instance" composeResourceIngress = "ingress" + composeResourceBuild = "build" ) type Runner struct { diff --git a/lib/compose/compose_test.go b/lib/compose/compose_test.go index 2653c32..beae1e3 100644 --- a/lib/compose/compose_test.go +++ b/lib/compose/compose_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/kernel/hypeman-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -177,6 +178,35 @@ services: require.NotEqual(t, builds[0].Image, changed[0].Image) } +func TestRunnableBuildImage(t *testing.T) { + assert.Equal(t, "builds/build-id", runnableBuildImage(&hypeman.Build{ + ID: "build-id", + ImageRef: "builds/build-id", + })) + assert.Equal(t, "docker.io/builds/build-id:latest", runnableBuildImage(&hypeman.Build{ + ID: "build-id", + })) +} + +func TestUpdateDesiredInstanceImageRehashesTags(t *testing.T) { + instances := []desiredInstance{{ + Service: "worker", + Input: map[string]any{ + "name": "worker-stack-worker", + "image": "compose/worker-stack/worker:original", + "tags": composeTags("worker-stack", "worker", composeResourceInstance, "old-hash"), + }, + }} + + require.NoError(t, updateDesiredInstanceImage(instances, "worker-stack", "worker", "builds/build-id")) + + assert.Equal(t, "builds/build-id", instances[0].Input["image"]) + tags := instances[0].Input["tags"].(map[string]string) + assert.Equal(t, composeResourceInstance, tags[composeTagResource]) + assert.NotEqual(t, "old-hash", tags[composeTagHash]) + assert.Equal(t, instances[0].Hash, tags[composeTagHash]) +} + func TestConflictBlockers(t *testing.T) { blockers := conflictBlockers([]Action{ {Action: "create", Type: "image", Name: "alpine:latest"}, diff --git a/lib/compose/desired.go b/lib/compose/desired.go index 9f0ee8f..55a7994 100644 --- a/lib/compose/desired.go +++ b/lib/compose/desired.go @@ -136,6 +136,26 @@ func buildComposeInstanceInput(instanceName string, service composeServiceSpec) return input } +func updateDesiredInstanceImage(instances []desiredInstance, composeName, serviceName, image string) error { + if image == "" { + return nil + } + for i := range instances { + if instances[i].Service != serviceName { + continue + } + instances[i].Input["image"] = image + delete(instances[i].Input, "tags") + hash, err := shortHash(instances[i].Input) + if err != nil { + return err + } + instances[i].Hash = hash + instances[i].Input["tags"] = composeTags(composeName, serviceName, composeResourceInstance, hash) + } + return nil +} + func buildComposeRestartPayload(restart *composeRestartSpec) map[string]any { payload := map[string]any{} if restart.Policy != "" { diff --git a/lib/compose/reconcile.go b/lib/compose/reconcile.go index 568bb6d..f1ff5b2 100644 --- a/lib/compose/reconcile.go +++ b/lib/compose/reconcile.go @@ -24,6 +24,11 @@ func (r *Runner) Plan(ctx context.Context) (Plan, error) { if err != nil { return Plan{}, err } + if action.buildInput != nil && action.buildInput.ImageRef != "" { + if err := updateDesiredInstanceImage(desiredInstances, r.spec.Name, build.Service, action.buildInput.ImageRef); err != nil { + return Plan{}, err + } + } actions = append(actions, action) } for _, image := range images { @@ -88,6 +93,11 @@ func (r *Runner) Up(ctx context.Context, opts UpOptions) (Plan, error) { if err := r.applyCreate(ctx, action, opts); err != nil { return result, err } + if action.Type == "build" { + if err := updatePlannedInstanceImage(result.Actions, r.spec.Name, action.Service, action.Name); err != nil { + return result, err + } + } case "replace": if opts.Verbose { fmt.Fprintf(os.Stderr, "[replace] %s %s\n", action.Type, action.Name) @@ -99,8 +109,9 @@ func (r *Runner) Up(ctx context.Context, opts UpOptions) (Plan, error) { if opts.Verbose { fmt.Fprintf(os.Stderr, "[skip] %s %s unchanged\n", action.Type, action.Name) } - if action.Type == "build" { - if err := r.waitBuiltImageReady(ctx, action.Name); err != nil { + if action.Type == "build" && action.buildInput != nil && action.buildInput.ImageRef != "" { + action.Name = action.buildInput.ImageRef + if err := updatePlannedInstanceImage(result.Actions, r.spec.Name, action.Service, action.Name); err != nil { return result, err } } @@ -207,7 +218,14 @@ func (r *Runner) applyCreate(ctx context.Context, action *Action, opts UpOptions if action.buildInput == nil { return fmt.Errorf("build action %s missing build input", action.Name) } - return r.runBuild(ctx, *action.buildInput, opts.Verbose) + imageRef, err := r.runBuild(ctx, *action.buildInput, opts.Verbose) + if err != nil { + return err + } + if imageRef != "" { + action.Name = imageRef + } + return nil case "image": return r.ensureImageReady(ctx, action.Name, opts.Verbose) case "instance": @@ -478,6 +496,25 @@ func summarizeComposeActions(actions []Action) Summary { return summary } +func updatePlannedInstanceImage(actions []Action, composeName, serviceName, image string) error { + if image == "" { + return nil + } + for i := range actions { + if actions[i].Type != "instance" || actions[i].Service != serviceName || actions[i].instanceInput == nil { + continue + } + actions[i].instanceInput["image"] = image + delete(actions[i].instanceInput, "tags") + hash, err := shortHash(actions[i].instanceInput) + if err != nil { + return err + } + actions[i].instanceInput["tags"] = composeTags(composeName, serviceName, composeResourceInstance, hash) + } + return nil +} + func isHTTPNotFound(err error) bool { apiErr, ok := err.(*hypeman.Error) return ok && apiErr.Response != nil && apiErr.Response.StatusCode == 404