From 99d7c20fbfbc942faf0c01204e02c03f0df8fe7a Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 16 May 2026 02:56:04 +0000 Subject: [PATCH 1/3] Add compose commands --- README.md | 44 ++ pkg/cmd/cmd.go | 1 + pkg/cmd/composecmd.go | 1059 ++++++++++++++++++++++++++++++++++++ pkg/cmd/composecmd_test.go | 136 +++++ 4 files changed, 1240 insertions(+) create mode 100644 pkg/cmd/composecmd.go create mode 100644 pkg/cmd/composecmd_test.go diff --git a/README.md b/README.md index d0a8e90..962f595 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,50 @@ hypeman ingress delete my-ingress hypeman rm --force --all ``` +### Compose + +Use `hypeman compose` to apply a small declarative workload file. It creates +normal images, instances, and ingresses, then tags the instances and ingresses +so re-running the command is idempotent. + +```yaml +version: 1 +name: hypeship-otel + +services: + otelcol: + image: otel/opentelemetry-collector-contrib:0.108.0 + cmd: ["--config=env:OTELCOL_CONFIG"] + env: + OTELCOL_CONFIG: ${file:otelcol.yaml} + SIGNOZ_ACCESS_TOKEN: ${env:SIGNOZ_ACCESS_TOKEN} + resources: + vcpus: 8 + memory: 4GB + restart: + policy: on_failure + backoff: 5s + max_attempts: 10 + healthcheck: + http: + port: 13133 + path: / + interval: 10s + timeout: 2s + failure_threshold: 3 + ingress: + - hostname: otel.example.com + host_port: 443 + target_port: 4318 + tls: true +``` + +```sh +hypeman compose plan -f hypeman.compose.yaml +hypeman compose up -f hypeman.compose.yaml +hypeman compose down -f hypeman.compose.yaml +``` + More ingress features: - Automatic certs - Subdomain-based routing diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 9eb5783..ce12444 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -93,6 +93,7 @@ func init() { &volumeCmd, &resourcesCmd, &deviceCmd, + &composeCmd, { Name: "@manpages", Usage: "Generate documentation for 'man'", diff --git a/pkg/cmd/composecmd.go b/pkg/cmd/composecmd.go new file mode 100644 index 0000000..f557dec --- /dev/null +++ b/pkg/cmd/composecmd.go @@ -0,0 +1,1059 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" + "gopkg.in/yaml.v3" +) + +const ( + composeTagName = "hypeman.compose.name" + composeTagService = "hypeman.compose.service" + composeTagResource = "hypeman.compose.resource" + composeTagHash = "hypeman.compose.hash" + + composeResourceInstance = "instance" + composeResourceIngress = "ingress" +) + +var composeCmd = cli.Command{ + Name: "compose", + Usage: "Apply a small declarative workload file", + Commands: []*cli.Command{ + &composePlanCmd, + &composeUpCmd, + &composeDownCmd, + }, + HideHelpCommand: true, +} + +var composePlanCmd = cli.Command{ + Name: "plan", + Usage: "Show compose changes without applying them", + Flags: composeFileFlags(), + Action: func(ctx context.Context, cmd *cli.Command) error { + runner, err := newComposeRunner(cmd) + if err != nil { + return err + } + result, err := runner.plan(ctx) + if err != nil { + return err + } + return showComposeResult(cmd, "compose plan", result) + }, + HideHelpCommand: true, +} + +var composeUpCmd = cli.Command{ + Name: "up", + Usage: "Create or update compose resources", + Flags: append(composeFileFlags(), + &cli.BoolFlag{ + Name: "replace", + Usage: "Recreate resources when immutable fields changed", + }, + &cli.BoolFlag{ + Name: "wait", + Usage: "Wait for newly created instances to reach Running", + Value: true, + }, + &cli.StringFlag{ + Name: "wait-timeout", + Usage: `Maximum wait per instance (e.g. "30s", "2m")`, + Value: "2m", + }, + ), + Action: func(ctx context.Context, cmd *cli.Command) error { + runner, err := newComposeRunner(cmd) + if err != nil { + return err + } + result, err := runner.up(ctx, composeUpOptions{ + Replace: cmd.Bool("replace"), + Wait: cmd.Bool("wait"), + WaitTimeout: cmd.String("wait-timeout"), + Verbose: cmd.Root().String("format") == "auto", + }) + if err != nil { + return err + } + if cmd.Root().String("format") != "auto" { + return showComposeResult(cmd, "compose up", result) + } + printComposeDone(result) + return nil + }, + HideHelpCommand: true, +} + +var composeDownCmd = cli.Command{ + Name: "down", + Usage: "Delete resources owned by a compose file", + Flags: composeFileFlags(), + Action: func(ctx context.Context, cmd *cli.Command) error { + runner, err := newComposeRunner(cmd) + if err != nil { + return err + } + result, err := runner.down(ctx, cmd.Root().String("format") == "auto") + if err != nil { + return err + } + if cmd.Root().String("format") != "auto" { + return showComposeResult(cmd, "compose down", result) + } + printComposeDone(result) + return nil + }, + HideHelpCommand: true, +} + +func composeFileFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "Compose file to apply", + Value: "hypeman.compose.yaml", + }, + } +} + +type composeRunner struct { + file string + spec composeSpec + client hypeman.Client + opts []option.RequestOption +} + +type composeUpOptions struct { + Replace bool + Wait bool + WaitTimeout string + Verbose bool +} + +type composeSpec struct { + Version int `json:"version" yaml:"version"` + Name string `json:"name" yaml:"name"` + Services map[string]composeServiceSpec `json:"services" yaml:"services"` +} + +type composeServiceSpec struct { + Image string `json:"image" yaml:"image"` + Entrypoint []string `json:"entrypoint,omitempty" yaml:"entrypoint"` + Cmd []string `json:"cmd,omitempty" yaml:"cmd"` + Env map[string]string `json:"env,omitempty" yaml:"env"` + Resources composeResourcesSpec `json:"resources,omitempty" yaml:"resources"` + Restart *composeRestartSpec `json:"restart,omitempty" yaml:"restart"` + Health *composeCheckSpec `json:"healthcheck,omitempty" yaml:"healthcheck"` + Ingress []composeIngressRuleSpec `json:"ingress,omitempty" yaml:"ingress"` +} + +type composeResourcesSpec struct { + Vcpus int `json:"vcpus,omitempty" yaml:"vcpus"` + Memory string `json:"memory,omitempty" yaml:"memory"` + OverlaySize string `json:"overlay_size,omitempty" yaml:"overlay_size"` + HotplugSize string `json:"hotplug_size,omitempty" yaml:"hotplug_size"` + DiskIOBps string `json:"disk_io_bps,omitempty" yaml:"disk_io_bps"` + BandwidthUpload string `json:"bandwidth_upload,omitempty" yaml:"bandwidth_upload"` + BandwidthDownload string `json:"bandwidth_download,omitempty" yaml:"bandwidth_download"` +} + +type composeRestartSpec struct { + Policy string `json:"policy,omitempty" yaml:"policy"` + Backoff string `json:"backoff,omitempty" yaml:"backoff"` + MaxAttempts int `json:"max_attempts,omitempty" yaml:"max_attempts"` + StableAfter string `json:"stable_after,omitempty" yaml:"stable_after"` +} + +type composeCheckSpec struct { + HTTP *composeHTTPCheckSpec `json:"http,omitempty" yaml:"http"` + TCP *composeTCPCheckSpec `json:"tcp,omitempty" yaml:"tcp"` + Exec *composeExecCheckSpec `json:"exec,omitempty" yaml:"exec"` + Type string `json:"type,omitempty" yaml:"type"` + Interval string `json:"interval,omitempty" yaml:"interval"` + Timeout string `json:"timeout,omitempty" yaml:"timeout"` + StartPeriod string `json:"start_period,omitempty" yaml:"start_period"` + FailureThreshold int `json:"failure_threshold,omitempty" yaml:"failure_threshold"` + SuccessThreshold int `json:"success_threshold,omitempty" yaml:"success_threshold"` +} + +type composeHTTPCheckSpec struct { + Port int `json:"port,omitempty" yaml:"port"` + Path string `json:"path,omitempty" yaml:"path"` + Scheme string `json:"scheme,omitempty" yaml:"scheme"` + ExpectedStatus int `json:"expected_status,omitempty" yaml:"expected_status"` +} + +type composeTCPCheckSpec struct { + Port int `json:"port,omitempty" yaml:"port"` +} + +type composeExecCheckSpec struct { + Command []string `json:"command,omitempty" yaml:"command"` + WorkingDir string `json:"working_dir,omitempty" yaml:"working_dir"` +} + +type composeIngressRuleSpec struct { + Hostname string `json:"hostname" yaml:"hostname"` + HostPort int `json:"host_port,omitempty" yaml:"host_port"` + TargetPort int `json:"target_port" yaml:"target_port"` + TLS bool `json:"tls,omitempty" yaml:"tls"` + RedirectHTTP bool `json:"redirect_http,omitempty" yaml:"redirect_http"` +} + +type composePlan struct { + Name string `json:"name"` + File string `json:"file"` + Actions []composeAction `json:"actions"` + Summary composeSummary `json:"summary"` +} + +type composeSummary struct { + Create int `json:"create"` + Replace int `json:"replace"` + Delete int `json:"delete"` + Unchanged int `json:"unchanged"` + Skip int `json:"skip"` + Conflict int `json:"conflict"` +} + +type composeAction struct { + Action string `json:"action"` + Type string `json:"type"` + Name string `json:"name"` + Service string `json:"service,omitempty"` + Reason string `json:"reason"` + + instanceID string + ingressID string + instanceInput map[string]any + ingressInput hypeman.IngressNewParams +} + +type desiredInstance struct { + Name string + Service string + Hash string + Input map[string]any +} + +type desiredIngress struct { + Name string + Service string + Hash string + Input hypeman.IngressNewParams +} + +func newComposeRunner(cmd *cli.Command) (*composeRunner, error) { + file := cmd.String("file") + spec, err := loadComposeSpec(file) + if err != nil { + return nil, err + } + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + return &composeRunner{ + file: file, + spec: spec, + client: client, + opts: opts, + }, nil +} + +func loadComposeSpec(path string) (composeSpec, error) { + data, err := os.ReadFile(path) + if err != nil { + return composeSpec{}, fmt.Errorf("read compose file: %w", err) + } + var spec composeSpec + if err := yaml.Unmarshal(data, &spec); err != nil { + return composeSpec{}, fmt.Errorf("parse compose file: %w", err) + } + if err := validateComposeSpec(&spec); err != nil { + return composeSpec{}, err + } + if err := interpolateComposeSpec(&spec, filepath.Dir(path)); err != nil { + return composeSpec{}, err + } + return spec, nil +} + +var composeNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +func validateComposeSpec(spec *composeSpec) error { + if spec.Version != 1 { + return fmt.Errorf("compose version must be 1") + } + if spec.Name == "" { + return fmt.Errorf("compose name is required") + } + if !composeNamePattern.MatchString(spec.Name) { + return fmt.Errorf("compose name must contain only lowercase letters, digits, and dashes") + } + if len(spec.Services) == 0 { + return fmt.Errorf("compose services must include at least one service") + } + + for name, service := range spec.Services { + if !composeNamePattern.MatchString(name) { + return fmt.Errorf("service %q must contain only lowercase letters, digits, and dashes", name) + } + instanceName := composeInstanceName(spec.Name, name) + 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) + } + for i, rule := range service.Ingress { + if rule.Hostname == "" { + return fmt.Errorf("service %q ingress %d hostname is required", name, i) + } + if rule.TargetPort <= 0 { + return fmt.Errorf("service %q ingress %d target_port must be positive", name, i) + } + if rule.HostPort < 0 { + return fmt.Errorf("service %q ingress %d host_port must be non-negative", name, i) + } + } + } + return nil +} + +var composeInterpolationPattern = regexp.MustCompile(`\$\{(file|env):([^}]+)\}`) + +func interpolateComposeSpec(spec *composeSpec, baseDir string) error { + for serviceName, service := range spec.Services { + for key, value := range service.Env { + resolved, err := interpolateComposeValue(value, baseDir) + if err != nil { + return fmt.Errorf("service %q env %s: %w", serviceName, key, err) + } + service.Env[key] = resolved + } + spec.Services[serviceName] = service + } + return nil +} + +func interpolateComposeValue(value, baseDir string) (string, error) { + var out strings.Builder + last := 0 + matches := composeInterpolationPattern.FindAllStringSubmatchIndex(value, -1) + for _, match := range matches { + out.WriteString(value[last:match[0]]) + kind := value[match[2]:match[3]] + arg := strings.TrimSpace(value[match[4]:match[5]]) + switch kind { + case "env": + envValue, ok := os.LookupEnv(arg) + if !ok { + return "", fmt.Errorf("environment variable %s is not set", arg) + } + out.WriteString(envValue) + case "file": + path := arg + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read file %s: %w", arg, err) + } + out.Write(data) + } + last = match[1] + } + out.WriteString(value[last:]) + return out.String(), nil +} + +func (r *composeRunner) plan(ctx context.Context) (composePlan, error) { + desiredInstances, desiredIngresses, images, err := r.desiredResources() + if err != nil { + return composePlan{}, err + } + + var actions []composeAction + for _, image := range images { + action, err := r.planImage(ctx, image) + if err != nil { + return composePlan{}, err + } + actions = append(actions, action) + } + + existingInstances, err := r.listComposeInstances(ctx) + if err != nil { + return composePlan{}, err + } + allInstances, err := r.client.Instances.List(ctx, hypeman.InstanceListParams{}, r.opts...) + if err != nil { + return composePlan{}, err + } + for _, inst := range desiredInstances { + actions = append(actions, planInstanceAction(inst, existingInstances, *allInstances)) + } + + existingIngresses, err := r.listComposeIngresses(ctx) + if err != nil { + return composePlan{}, err + } + allIngresses, err := r.client.Ingresses.List(ctx, hypeman.IngressListParams{}, r.opts...) + if err != nil { + return composePlan{}, err + } + for _, ingress := range desiredIngresses { + actions = append(actions, planIngressAction(ingress, existingIngresses, *allIngresses)) + } + + return composePlan{ + Name: r.spec.Name, + File: r.file, + Actions: actions, + Summary: summarizeComposeActions(actions), + }, nil +} + +func (r *composeRunner) up(ctx context.Context, opts composeUpOptions) (composePlan, error) { + result, err := r.plan(ctx) + if err != nil { + return composePlan{}, err + } + if blockers := conflictBlockers(result.Actions); len(blockers) > 0 { + return result, fmt.Errorf("conflicts found:\n%s", strings.Join(blockers, "\n")) + } + if blockers := replacementBlockers(result.Actions, opts.Replace); len(blockers) > 0 { + return result, fmt.Errorf("replace required:\n%s\n\nRun again with --replace to recreate changed resources.", strings.Join(blockers, "\n")) + } + + for i := range result.Actions { + action := &result.Actions[i] + switch action.Action { + case "create": + if opts.Verbose { + fmt.Fprintf(os.Stderr, "[create] %s %s\n", action.Type, action.Name) + } + if err := r.applyCreate(ctx, action, opts); err != nil { + return result, err + } + case "replace": + if opts.Verbose { + fmt.Fprintf(os.Stderr, "[replace] %s %s\n", action.Type, action.Name) + } + if err := r.applyReplace(ctx, action, opts); err != nil { + return result, err + } + case "unchanged": + if opts.Verbose { + fmt.Fprintf(os.Stderr, "[skip] %s %s unchanged\n", action.Type, action.Name) + } + if action.Type == "image" { + if err := r.ensureImageReady(ctx, action.Name, opts.Verbose); err != nil { + return result, err + } + } + case "conflict": + return result, fmt.Errorf("%s %s already exists without compose ownership tags", action.Type, action.Name) + } + } + + return result, nil +} + +func (r *composeRunner) down(ctx context.Context, verbose bool) (composePlan, error) { + instances, err := r.listComposeInstances(ctx) + if err != nil { + return composePlan{}, err + } + ingresses, err := r.listComposeIngresses(ctx) + if err != nil { + return composePlan{}, err + } + + var actions []composeAction + for _, ing := range ingresses { + actions = append(actions, composeAction{ + Action: "delete", + Type: "ingress", + Name: ing.Name, + Service: ing.Tags[composeTagService], + Reason: "owned by compose file", + ingressID: ing.ID, + }) + } + for _, inst := range instances { + actions = append(actions, composeAction{ + Action: "delete", + Type: "instance", + Name: inst.Name, + Service: inst.Tags[composeTagService], + Reason: "owned by compose file", + instanceID: inst.ID, + }) + } + sortComposeActions(actions) + + result := composePlan{ + Name: r.spec.Name, + File: r.file, + Actions: actions, + Summary: summarizeComposeActions(actions), + } + if len(actions) == 0 { + _, desiredIngresses, _, err := r.desiredResources() + if err != nil { + return composePlan{}, err + } + for _, ingress := range desiredIngresses { + result.Actions = append(result.Actions, composeAction{ + Action: "skip", + Type: "ingress", + Name: ingress.Name, + Service: ingress.Service, + Reason: "not found", + }) + } + for serviceName := range r.spec.Services { + result.Actions = append(result.Actions, composeAction{ + Action: "skip", + Type: "instance", + Name: composeInstanceName(r.spec.Name, serviceName), + Service: serviceName, + Reason: "not found", + }) + } + sortComposeActions(result.Actions) + result.Summary = summarizeComposeActions(result.Actions) + return result, nil + } + + for i := range actions { + action := &actions[i] + if verbose { + fmt.Fprintf(os.Stderr, "[delete] %s %s\n", action.Type, action.Name) + } + switch action.Type { + case "ingress": + if err := r.client.Ingresses.Delete(ctx, action.ingressID, r.opts...); err != nil && !isHTTPNotFound(err) { + return result, err + } + case "instance": + if err := r.client.Instances.Delete(ctx, action.instanceID, r.opts...); err != nil && !isHTTPNotFound(err) { + return result, err + } + } + } + + return result, nil +} + +func (r *composeRunner) applyCreate(ctx context.Context, action *composeAction, opts composeUpOptions) error { + switch action.Type { + case "image": + return r.ensureImageReady(ctx, action.Name, opts.Verbose) + case "instance": + var inst hypeman.Instance + if err := r.client.Post(ctx, "instances", action.instanceInput, &inst, r.opts...); err != nil { + return err + } + action.instanceID = inst.ID + if opts.Wait { + return r.waitForInstanceRunning(ctx, inst.ID, opts.WaitTimeout, opts.Verbose) + } + case "ingress": + ing, err := r.client.Ingresses.New(ctx, action.ingressInput, r.opts...) + if err != nil { + return err + } + action.ingressID = ing.ID + } + return nil +} + +func (r *composeRunner) applyReplace(ctx context.Context, action *composeAction, opts composeUpOptions) error { + switch action.Type { + case "instance": + if action.instanceID != "" { + if err := r.client.Instances.Delete(ctx, action.instanceID, r.opts...); err != nil && !isHTTPNotFound(err) { + return err + } + } + case "ingress": + if action.ingressID != "" { + if err := r.client.Ingresses.Delete(ctx, action.ingressID, r.opts...); err != nil && !isHTTPNotFound(err) { + return err + } + } + } + createAction := *action + createAction.Action = "create" + if err := r.applyCreate(ctx, &createAction, opts); err != nil { + return err + } + action.instanceID = createAction.instanceID + action.ingressID = createAction.ingressID + return nil +} + +func (r *composeRunner) ensureImageReady(ctx context.Context, image string, verbose bool) error { + img, err := r.client.Images.Get(ctx, url.PathEscape(image), r.opts...) + if err != nil { + if !isHTTPNotFound(err) { + return fmt.Errorf("check image %s: %w", image, err) + } + img, err = r.client.Images.New(ctx, hypeman.ImageNewParams{Name: image}, r.opts...) + if err != nil { + return fmt.Errorf("create image %s: %w", image, err) + } + } + if verbose && img.Status != hypeman.ImageStatusReady { + fmt.Fprintf(os.Stderr, "[wait] image %s ready\n", image) + } + return waitForImageReady(ctx, &r.client, img) +} + +func (r *composeRunner) waitForInstanceRunning(ctx context.Context, instanceID, timeout string, verbose bool) error { + if verbose { + fmt.Fprintf(os.Stderr, "[wait] instance %s running\n", instanceID) + } + params := hypeman.InstanceWaitParams{ + State: hypeman.InstanceWaitParamsStateRunning, + } + if timeout != "" { + params.Timeout = hypeman.Opt(timeout) + } + resp, err := r.client.Instances.Wait(ctx, instanceID, params, r.opts...) + if err != nil { + return err + } + if resp.TimedOut { + return fmt.Errorf("timed out waiting for instance %s to reach Running", instanceID) + } + return nil +} + +func (r *composeRunner) desiredResources() ([]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{}{} + } + sort.Strings(serviceNames) + + images := make([]string, 0, len(imageSet)) + for image := range imageSet { + images = append(images, image) + } + sort.Strings(images) + + instances := make([]desiredInstance, 0, len(serviceNames)) + var ingresses []desiredIngress + for _, serviceName := range serviceNames { + service := r.spec.Services[serviceName] + instanceName := composeInstanceName(r.spec.Name, serviceName) + instanceInput := buildComposeInstanceInput(instanceName, service) + instanceHash, err := shortHash(instanceInput) + if err != nil { + return nil, nil, nil, err + } + instanceInput["tags"] = composeTags(r.spec.Name, serviceName, composeResourceInstance, instanceHash) + instances = append(instances, desiredInstance{ + Name: instanceName, + Service: serviceName, + Hash: instanceHash, + Input: instanceInput, + }) + + for i, ingressSpec := range service.Ingress { + ingressName := composeIngressName(r.spec.Name, serviceName, i) + ingressInput := buildComposeIngressInput(instanceName, ingressName, ingressSpec) + ingressHash, err := shortHash(ingressInput) + if err != nil { + return nil, nil, nil, err + } + ingressInput.Tags = composeTags(r.spec.Name, serviceName, composeResourceIngress, ingressHash) + ingresses = append(ingresses, desiredIngress{ + Name: ingressName, + Service: serviceName, + Hash: ingressHash, + Input: ingressInput, + }) + } + } + return instances, ingresses, images, nil +} + +func buildComposeInstanceInput(instanceName string, service composeServiceSpec) map[string]any { + input := map[string]any{ + "name": instanceName, + "image": service.Image, + } + if len(service.Entrypoint) > 0 { + input["entrypoint"] = service.Entrypoint + } + if len(service.Cmd) > 0 { + input["cmd"] = service.Cmd + } + if len(service.Env) > 0 { + input["env"] = service.Env + } + if service.Resources.Vcpus > 0 { + input["vcpus"] = service.Resources.Vcpus + } + if service.Resources.Memory != "" { + input["size"] = service.Resources.Memory + } + if service.Resources.OverlaySize != "" { + input["overlay_size"] = service.Resources.OverlaySize + } + if service.Resources.HotplugSize != "" { + input["hotplug_size"] = service.Resources.HotplugSize + } + if service.Resources.DiskIOBps != "" { + input["disk_io_bps"] = service.Resources.DiskIOBps + } + if service.Resources.BandwidthDownload != "" || service.Resources.BandwidthUpload != "" { + network := map[string]any{} + if service.Resources.BandwidthDownload != "" { + network["bandwidth_download"] = service.Resources.BandwidthDownload + } + if service.Resources.BandwidthUpload != "" { + network["bandwidth_upload"] = service.Resources.BandwidthUpload + } + input["network"] = network + } + if service.Restart != nil { + input["restart_policy"] = buildComposeRestartPayload(service.Restart) + } + if service.Health != nil { + input["health_check"] = service.Health + } + return input +} + +func buildComposeRestartPayload(restart *composeRestartSpec) map[string]any { + payload := map[string]any{} + if restart.Policy != "" { + payload["policy"] = strings.ReplaceAll(restart.Policy, "-", "_") + } + if restart.Backoff != "" { + payload["backoff"] = restart.Backoff + } + if restart.MaxAttempts > 0 { + payload["max_attempts"] = restart.MaxAttempts + } + if restart.StableAfter != "" { + payload["stable_after"] = restart.StableAfter + } + return payload +} + +func buildComposeIngressInput(instanceName, ingressName string, spec composeIngressRuleSpec) hypeman.IngressNewParams { + hostPort := spec.HostPort + if hostPort == 0 { + hostPort = 80 + } + return hypeman.IngressNewParams{ + Name: ingressName, + Rules: []hypeman.IngressRuleParam{ + { + Match: hypeman.IngressMatchParam{ + Hostname: spec.Hostname, + Port: hypeman.Int(int64(hostPort)), + }, + Target: hypeman.IngressTargetParam{ + Instance: instanceName, + Port: int64(spec.TargetPort), + }, + Tls: hypeman.Bool(spec.TLS), + RedirectHTTP: hypeman.Bool(spec.RedirectHTTP), + }, + }, + } +} + +func (r *composeRunner) planImage(ctx context.Context, image string) (composeAction, error) { + _, err := r.client.Images.Get(ctx, url.PathEscape(image), r.opts...) + if err == nil { + return composeAction{Action: "unchanged", Type: "image", Name: image, Reason: "already exists"}, nil + } + if isHTTPNotFound(err) { + return composeAction{Action: "create", Type: "image", Name: image, Reason: "not present"}, nil + } + return composeAction{}, fmt.Errorf("check image %s: %w", image, err) +} + +func planInstanceAction(desired desiredInstance, owned []hypeman.Instance, all []hypeman.Instance) composeAction { + action := composeAction{ + Type: "instance", + Name: desired.Name, + Service: desired.Service, + instanceInput: desired.Input, + } + for _, inst := range owned { + if inst.Name != desired.Name { + continue + } + action.instanceID = inst.ID + if inst.Tags[composeTagHash] == desired.Hash { + action.Action = "unchanged" + action.Reason = "hash matches" + return action + } + action.Action = "replace" + if inst.Tags[composeTagHash] == "" { + action.Reason = "missing compose hash" + } else { + action.Reason = "rendered spec changed" + } + return action + } + for _, inst := range all { + if inst.Name == desired.Name { + action.Action = "conflict" + action.Reason = "name exists without compose ownership" + action.instanceID = inst.ID + return action + } + } + action.Action = "create" + action.Reason = "missing" + return action +} + +func planIngressAction(desired desiredIngress, owned []hypeman.Ingress, all []hypeman.Ingress) composeAction { + action := composeAction{ + Type: "ingress", + Name: desired.Name, + Service: desired.Service, + ingressInput: desired.Input, + } + for _, ing := range owned { + if ing.Name != desired.Name { + continue + } + action.ingressID = ing.ID + if ing.Tags[composeTagHash] == desired.Hash { + action.Action = "unchanged" + action.Reason = "hash matches" + return action + } + action.Action = "replace" + if ing.Tags[composeTagHash] == "" { + action.Reason = "missing compose hash" + } else { + action.Reason = "rendered spec changed" + } + return action + } + for _, ing := range all { + if ing.Name == desired.Name { + action.Action = "conflict" + action.Reason = "name exists without compose ownership" + action.ingressID = ing.ID + return action + } + } + action.Action = "create" + action.Reason = "missing" + return action +} + +func (r *composeRunner) listComposeInstances(ctx context.Context) ([]hypeman.Instance, error) { + instances, err := r.client.Instances.List(ctx, hypeman.InstanceListParams{ + Tags: map[string]string{composeTagName: r.spec.Name}, + }, r.opts...) + if err != nil { + return nil, err + } + return *instances, nil +} + +func (r *composeRunner) listComposeIngresses(ctx context.Context) ([]hypeman.Ingress, error) { + ingresses, err := r.client.Ingresses.List(ctx, hypeman.IngressListParams{ + Tags: map[string]string{composeTagName: r.spec.Name}, + }, r.opts...) + if err != nil { + return nil, err + } + return *ingresses, nil +} + +func replacementBlockers(actions []composeAction, replace bool) []string { + if replace { + return nil + } + var blockers []string + for _, action := range actions { + if action.Action == "replace" { + blockers = append(blockers, fmt.Sprintf(" %s %s changed: %s", action.Type, action.Name, action.Reason)) + } + } + return blockers +} + +func conflictBlockers(actions []composeAction) []string { + var blockers []string + for _, action := range actions { + if action.Action == "conflict" { + blockers = append(blockers, fmt.Sprintf(" %s %s: %s", action.Type, action.Name, action.Reason)) + } + } + return blockers +} + +func summarizeComposeActions(actions []composeAction) composeSummary { + var summary composeSummary + for _, action := range actions { + switch action.Action { + case "create": + summary.Create++ + case "replace": + summary.Replace++ + case "delete": + summary.Delete++ + case "unchanged": + summary.Unchanged++ + case "skip": + summary.Skip++ + case "conflict": + summary.Conflict++ + } + } + return summary +} + +func showComposeResult(cmd *cli.Command, title string, result composePlan) error { + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + if format == "auto" { + printComposePlan(result) + return nil + } + data, err := json.Marshal(result) + if err != nil { + return err + } + return ShowJSON(os.Stdout, title, gjson.ParseBytes(data), format, transform) +} + +func printComposePlan(result composePlan) { + fmt.Fprintf(os.Stdout, "Compose file: %s\n", result.File) + fmt.Fprintf(os.Stdout, "Name: %s\n\n", result.Name) + if len(result.Actions) == 0 { + fmt.Fprintln(os.Stdout, "No resources found.") + fmt.Fprintln(os.Stdout) + printComposeSummary(result.Summary) + return + } + table := NewTableWriter(os.Stdout, "ACTION", "TYPE", "NAME", "REASON") + table.TruncOrder = []int{2, 3} + for _, action := range result.Actions { + table.AddRow(action.Action, action.Type, action.Name, action.Reason) + } + table.Render() + fmt.Fprintln(os.Stdout) + printComposeSummary(result.Summary) +} + +func printComposeDone(result composePlan) { + fmt.Fprintln(os.Stderr) + printComposeSummaryTo(os.Stderr, "Done", result.Summary) +} + +func printComposeSummary(summary composeSummary) { + printComposeSummaryTo(os.Stdout, "Summary", summary) +} + +func printComposeSummaryTo(out *os.File, label string, summary composeSummary) { + parts := []string{} + if summary.Create > 0 { + parts = append(parts, fmt.Sprintf("%d to create", summary.Create)) + } + if summary.Replace > 0 { + parts = append(parts, fmt.Sprintf("%d to replace", summary.Replace)) + } + if summary.Delete > 0 { + parts = append(parts, fmt.Sprintf("%d to delete", summary.Delete)) + } + if summary.Unchanged > 0 { + parts = append(parts, fmt.Sprintf("%d unchanged", summary.Unchanged)) + } + if summary.Skip > 0 { + parts = append(parts, fmt.Sprintf("%d skipped", summary.Skip)) + } + if summary.Conflict > 0 { + parts = append(parts, fmt.Sprintf("%d conflicts", summary.Conflict)) + } + if len(parts) == 0 { + parts = append(parts, "no changes") + } + fmt.Fprintf(out, "%s: %s\n", label, strings.Join(parts, ", ")) +} + +func composeInstanceName(composeName, serviceName string) string { + return composeName + "-" + serviceName +} + +func composeIngressName(composeName, serviceName string, index int) string { + return fmt.Sprintf("%s-%s-%d", composeName, serviceName, index) +} + +func composeTags(composeName, serviceName, resource, hash string) map[string]string { + return map[string]string{ + composeTagName: composeName, + composeTagService: serviceName, + composeTagResource: resource, + composeTagHash: hash, + } +} + +func shortHash(v any) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:])[:12], nil +} + +func isHTTPNotFound(err error) bool { + var apiErr *hypeman.Error + return isNotFoundError(err, &apiErr) +} + +func sortComposeActions(actions []composeAction) { + order := map[string]int{ + "image": 0, + "ingress": 1, + "instance": 2, + } + sort.SliceStable(actions, func(i, j int) bool { + if order[actions[i].Type] != order[actions[j].Type] { + return order[actions[i].Type] < order[actions[j].Type] + } + return actions[i].Name < actions[j].Name + }) +} diff --git a/pkg/cmd/composecmd_test.go b/pkg/cmd/composecmd_test.go new file mode 100644 index 0000000..9f223f1 --- /dev/null +++ b/pkg/cmd/composecmd_test.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadComposeSpecInterpolatesFilesAndEnv(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "otelcol.yaml"), []byte("receivers: {}\n"), 0644)) + t.Setenv("SIGNOZ_ACCESS_TOKEN", "secret-token") + + composePath := filepath.Join(dir, "hypeman.compose.yaml") + require.NoError(t, os.WriteFile(composePath, []byte(` +version: 1 +name: hypeship-otel +services: + otelcol: + image: otel/opentelemetry-collector-contrib:0.108.0 + cmd: ["--config=env:OTELCOL_CONFIG"] + env: + OTELCOL_CONFIG: ${file:otelcol.yaml} + SIGNOZ_ACCESS_TOKEN: ${env:SIGNOZ_ACCESS_TOKEN} +`), 0644)) + + spec, err := loadComposeSpec(composePath) + require.NoError(t, err) + + service := spec.Services["otelcol"] + assert.Equal(t, "receivers: {}\n", service.Env["OTELCOL_CONFIG"]) + assert.Equal(t, "secret-token", service.Env["SIGNOZ_ACCESS_TOKEN"]) +} + +func TestBuildComposeInstanceInputIncludesFuturePolicyFields(t *testing.T) { + service := composeServiceSpec{ + Image: "otel/opentelemetry-collector-contrib:0.108.0", + Cmd: []string{"--config=env:OTELCOL_CONFIG"}, + Env: map[string]string{ + "OTELCOL_CONFIG": "receivers: {}\n", + }, + Resources: composeResourcesSpec{ + Vcpus: 8, + Memory: "4GB", + BandwidthUpload: "300Mbps", + BandwidthDownload: "300Mbps", + }, + Restart: &composeRestartSpec{ + Policy: "on-failure", + Backoff: "5s", + MaxAttempts: 10, + StableAfter: "10m", + }, + Health: &composeCheckSpec{ + HTTP: &composeHTTPCheckSpec{Port: 13133, Path: "/", ExpectedStatus: 200}, + Interval: "10s", + Timeout: "2s", + FailureThreshold: 3, + }, + } + + input := buildComposeInstanceInput("hypeship-otel-otelcol", service) + + assert.Equal(t, "hypeship-otel-otelcol", input["name"]) + assert.Equal(t, service.Image, input["image"]) + assert.Equal(t, []string{"--config=env:OTELCOL_CONFIG"}, input["cmd"]) + assert.Equal(t, "4GB", input["size"]) + assert.Equal(t, 8, input["vcpus"]) + assert.Equal(t, map[string]any{ + "backoff": "5s", + "max_attempts": 10, + "policy": "on_failure", + "stable_after": "10m", + }, input["restart_policy"]) + assert.Equal(t, service.Health, input["health_check"]) + assert.Equal(t, map[string]any{ + "bandwidth_download": "300Mbps", + "bandwidth_upload": "300Mbps", + }, input["network"]) +} + +func TestDesiredResourcesUseDeterministicNamesAndTags(t *testing.T) { + runner := composeRunner{ + spec: composeSpec{ + Version: 1, + Name: "hypeship-otel", + Services: map[string]composeServiceSpec{ + "otelcol": { + Image: "otel/opentelemetry-collector-contrib:0.108.0", + Ingress: []composeIngressRuleSpec{ + {Hostname: "otel.example.com", HostPort: 443, TargetPort: 4318, TLS: true}, + }, + }, + }, + }, + } + + instances, ingresses, images, err := runner.desiredResources() + require.NoError(t, err) + + require.Equal(t, []string{"otel/opentelemetry-collector-contrib:0.108.0"}, images) + require.Len(t, instances, 1) + assert.Equal(t, "hypeship-otel-otelcol", instances[0].Name) + assert.Equal(t, composeResourceInstance, instances[0].Input["tags"].(map[string]string)[composeTagResource]) + assert.NotEmpty(t, instances[0].Input["tags"].(map[string]string)[composeTagHash]) + + require.Len(t, ingresses, 1) + assert.Equal(t, "hypeship-otel-otelcol-0", ingresses[0].Name) + assert.Equal(t, composeResourceIngress, ingresses[0].Input.Tags[composeTagResource]) + assert.Equal(t, "hypeship-otel-otelcol", ingresses[0].Input.Rules[0].Target.Instance) + assert.Equal(t, int64(4318), ingresses[0].Input.Rules[0].Target.Port) +} + +func TestValidateComposeSpecRejectsInvalidNames(t *testing.T) { + err := validateComposeSpec(&composeSpec{ + Version: 1, + Name: "BadName", + Services: map[string]composeServiceSpec{ + "api": {Image: "alpine:latest"}, + }, + }) + + require.EqualError(t, err, "compose name must contain only lowercase letters, digits, and dashes") +} + +func TestConflictBlockers(t *testing.T) { + blockers := conflictBlockers([]composeAction{ + {Action: "create", Type: "image", Name: "alpine:latest"}, + {Action: "conflict", Type: "instance", Name: "app-api", Reason: "name exists without compose ownership"}, + }) + + require.Equal(t, []string{" instance app-api: name exists without compose ownership"}, blockers) +} From 26e7b743c7c985f15716fd200eb9c0cc7ce9dd15 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 16 May 2026 02:57:34 +0000 Subject: [PATCH 2/3] Document compose commands --- README.md | 42 +------------------ pkg/cmd/README.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 41 deletions(-) create mode 100644 pkg/cmd/README.md diff --git a/README.md b/README.md index 962f595..45df7ac 100644 --- a/README.md +++ b/README.md @@ -80,47 +80,7 @@ hypeman rm --force --all ### Compose -Use `hypeman compose` to apply a small declarative workload file. It creates -normal images, instances, and ingresses, then tags the instances and ingresses -so re-running the command is idempotent. - -```yaml -version: 1 -name: hypeship-otel - -services: - otelcol: - image: otel/opentelemetry-collector-contrib:0.108.0 - cmd: ["--config=env:OTELCOL_CONFIG"] - env: - OTELCOL_CONFIG: ${file:otelcol.yaml} - SIGNOZ_ACCESS_TOKEN: ${env:SIGNOZ_ACCESS_TOKEN} - resources: - vcpus: 8 - memory: 4GB - restart: - policy: on_failure - backoff: 5s - max_attempts: 10 - healthcheck: - http: - port: 13133 - path: / - interval: 10s - timeout: 2s - failure_threshold: 3 - ingress: - - hostname: otel.example.com - host_port: 443 - target_port: 4318 - tls: true -``` - -```sh -hypeman compose plan -f hypeman.compose.yaml -hypeman compose up -f hypeman.compose.yaml -hypeman compose down -f hypeman.compose.yaml -``` +`hypeman compose` applies a small declarative workload file for images, instances, restart/health settings, and ingresses. See [pkg/cmd/README.md](pkg/cmd/README.md#compose). More ingress features: - Automatic certs diff --git a/pkg/cmd/README.md b/pkg/cmd/README.md new file mode 100644 index 0000000..baf8b2b --- /dev/null +++ b/pkg/cmd/README.md @@ -0,0 +1,104 @@ +# Command Features + +## Compose + +`hypeman compose` is a lightweight way to declare a small workload and apply it through the existing Hypeman API. It is not a replacement for every Docker Compose feature; it focuses on the pieces Hypeman already manages well: images, instances, restart policy, health checks, and ingresses. + +Compose files default to `hypeman.compose.yaml`: + +```yaml +version: 1 +name: hypeship-otel + +services: + otelcol: + image: otel/opentelemetry-collector-contrib:0.108.0 + cmd: ["--config=env:OTELCOL_CONFIG"] + env: + OTELCOL_CONFIG: ${file:otelcol.yaml} + SIGNOZ_ACCESS_TOKEN: ${env:SIGNOZ_ACCESS_TOKEN} + resources: + vcpus: 8 + memory: 4GB + restart: + policy: on_failure + backoff: 5s + max_attempts: 10 + healthcheck: + http: + port: 13133 + path: / + interval: 10s + timeout: 2s + failure_threshold: 3 + ingress: + - hostname: otel.example.com + host_port: 443 + target_port: 4318 + tls: true +``` + +### Commands + +Preview the changes: + +```sh +hypeman compose plan -f hypeman.compose.yaml +``` + +Apply the file: + +```sh +hypeman compose up -f hypeman.compose.yaml +``` + +Delete resources owned by the file: + +```sh +hypeman compose down -f hypeman.compose.yaml +``` + +`up` waits for newly created instances to reach `Running` by default. Use `--wait=false` to skip that wait, or `--wait-timeout 30s` to change the per-instance timeout. + +If a managed instance or ingress exists but the rendered spec changed, `up` reports that replacement is required and exits without changing resources. Re-run with `--replace` to recreate changed resources. + +All compose commands honor global output flags such as `--format json`, `--format yaml`, and `--transform`. + +### How It Works + +`plan` renders the desired resources from the compose file, checks whether referenced images exist, then compares the desired instances and ingresses against existing resources. + +`up` applies the plan in order: + +1. ensure referenced images exist and are ready +2. create or replace instances +3. 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. + +Instances and ingresses get compose ownership tags: + +```text +hypeman.compose.name +hypeman.compose.service +hypeman.compose.resource +hypeman.compose.hash +``` + +The hash is computed from the rendered resource spec before ownership tags are added. Re-running the same file is idempotent: matching resources are reported as unchanged, changed managed resources require `--replace`, and unmanaged resources with the same name are reported as conflicts. + +### Environment Values + +Environment values can embed local files or environment variables: + +```yaml +env: + OTELCOL_CONFIG: ${file:otelcol.yaml} + SIGNOZ_ACCESS_TOKEN: ${env:SIGNOZ_ACCESS_TOKEN} +``` + +File paths are resolved relative to the compose file. Missing files or environment variables fail before any resources are applied. + +### 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. From 6924e2a050bbde35ddc5fd798dbdf19c40ce5678 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 16 May 2026 03:13:02 +0000 Subject: [PATCH 3/3] Move compose logic to lib package --- README.md | 2 +- {pkg/cmd => lib/compose}/README.md | 0 lib/compose/compose.go | 916 ++++++++++++++++++ .../compose/compose_test.go | 6 +- pkg/cmd/composecmd.go | 916 +----------------- 5 files changed, 945 insertions(+), 895 deletions(-) rename {pkg/cmd => lib/compose}/README.md (100%) create mode 100644 lib/compose/compose.go rename pkg/cmd/composecmd_test.go => lib/compose/compose_test.go (98%) diff --git a/README.md b/README.md index 45df7ac..8d7c01a 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 [pkg/cmd/README.md](pkg/cmd/README.md#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). More ingress features: - Automatic certs diff --git a/pkg/cmd/README.md b/lib/compose/README.md similarity index 100% rename from pkg/cmd/README.md rename to lib/compose/README.md diff --git a/lib/compose/compose.go b/lib/compose/compose.go new file mode 100644 index 0000000..490d205 --- /dev/null +++ b/lib/compose/compose.go @@ -0,0 +1,916 @@ +package compose + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "gopkg.in/yaml.v3" +) + +const ( + composeTagName = "hypeman.compose.name" + composeTagService = "hypeman.compose.service" + composeTagResource = "hypeman.compose.resource" + composeTagHash = "hypeman.compose.hash" + + composeResourceInstance = "instance" + composeResourceIngress = "ingress" +) + +type Runner struct { + file string + spec composeSpec + client hypeman.Client + opts []option.RequestOption +} + +type UpOptions struct { + Replace bool + Wait bool + WaitTimeout string + Verbose bool +} + +type composeSpec struct { + Version int `json:"version" yaml:"version"` + Name string `json:"name" yaml:"name"` + Services map[string]composeServiceSpec `json:"services" yaml:"services"` +} + +type composeServiceSpec struct { + Image string `json:"image" yaml:"image"` + Entrypoint []string `json:"entrypoint,omitempty" yaml:"entrypoint"` + Cmd []string `json:"cmd,omitempty" yaml:"cmd"` + Env map[string]string `json:"env,omitempty" yaml:"env"` + Resources composeResourcesSpec `json:"resources,omitempty" yaml:"resources"` + Restart *composeRestartSpec `json:"restart,omitempty" yaml:"restart"` + Health *composeCheckSpec `json:"healthcheck,omitempty" yaml:"healthcheck"` + Ingress []composeIngressRuleSpec `json:"ingress,omitempty" yaml:"ingress"` +} + +type composeResourcesSpec struct { + Vcpus int `json:"vcpus,omitempty" yaml:"vcpus"` + Memory string `json:"memory,omitempty" yaml:"memory"` + OverlaySize string `json:"overlay_size,omitempty" yaml:"overlay_size"` + HotplugSize string `json:"hotplug_size,omitempty" yaml:"hotplug_size"` + DiskIOBps string `json:"disk_io_bps,omitempty" yaml:"disk_io_bps"` + BandwidthUpload string `json:"bandwidth_upload,omitempty" yaml:"bandwidth_upload"` + BandwidthDownload string `json:"bandwidth_download,omitempty" yaml:"bandwidth_download"` +} + +type composeRestartSpec struct { + Policy string `json:"policy,omitempty" yaml:"policy"` + Backoff string `json:"backoff,omitempty" yaml:"backoff"` + MaxAttempts int `json:"max_attempts,omitempty" yaml:"max_attempts"` + StableAfter string `json:"stable_after,omitempty" yaml:"stable_after"` +} + +type composeCheckSpec struct { + HTTP *composeHTTPCheckSpec `json:"http,omitempty" yaml:"http"` + TCP *composeTCPCheckSpec `json:"tcp,omitempty" yaml:"tcp"` + Exec *composeExecCheckSpec `json:"exec,omitempty" yaml:"exec"` + Type string `json:"type,omitempty" yaml:"type"` + Interval string `json:"interval,omitempty" yaml:"interval"` + Timeout string `json:"timeout,omitempty" yaml:"timeout"` + StartPeriod string `json:"start_period,omitempty" yaml:"start_period"` + FailureThreshold int `json:"failure_threshold,omitempty" yaml:"failure_threshold"` + SuccessThreshold int `json:"success_threshold,omitempty" yaml:"success_threshold"` +} + +type composeHTTPCheckSpec struct { + Port int `json:"port,omitempty" yaml:"port"` + Path string `json:"path,omitempty" yaml:"path"` + Scheme string `json:"scheme,omitempty" yaml:"scheme"` + ExpectedStatus int `json:"expected_status,omitempty" yaml:"expected_status"` +} + +type composeTCPCheckSpec struct { + Port int `json:"port,omitempty" yaml:"port"` +} + +type composeExecCheckSpec struct { + Command []string `json:"command,omitempty" yaml:"command"` + WorkingDir string `json:"working_dir,omitempty" yaml:"working_dir"` +} + +type composeIngressRuleSpec struct { + Hostname string `json:"hostname" yaml:"hostname"` + HostPort int `json:"host_port,omitempty" yaml:"host_port"` + TargetPort int `json:"target_port" yaml:"target_port"` + TLS bool `json:"tls,omitempty" yaml:"tls"` + RedirectHTTP bool `json:"redirect_http,omitempty" yaml:"redirect_http"` +} + +type Plan struct { + Name string `json:"name"` + File string `json:"file"` + Actions []Action `json:"actions"` + Summary Summary `json:"summary"` +} + +type Summary struct { + Create int `json:"create"` + Replace int `json:"replace"` + Delete int `json:"delete"` + Unchanged int `json:"unchanged"` + Skip int `json:"skip"` + Conflict int `json:"conflict"` +} + +type Action struct { + Action string `json:"action"` + Type string `json:"type"` + Name string `json:"name"` + Service string `json:"service,omitempty"` + Reason string `json:"reason"` + + instanceID string + ingressID string + instanceInput map[string]any + ingressInput hypeman.IngressNewParams +} + +type desiredInstance struct { + Name string + Service string + Hash string + Input map[string]any +} + +type desiredIngress struct { + Name string + Service string + Hash string + Input hypeman.IngressNewParams +} + +func NewRunner(file string, client hypeman.Client, opts ...option.RequestOption) (*Runner, error) { + spec, err := loadComposeSpec(file) + if err != nil { + return nil, err + } + return &Runner{ + file: file, + spec: spec, + client: client, + opts: opts, + }, nil +} + +func loadComposeSpec(path string) (composeSpec, error) { + data, err := os.ReadFile(path) + if err != nil { + return composeSpec{}, fmt.Errorf("read compose file: %w", err) + } + var spec composeSpec + if err := yaml.Unmarshal(data, &spec); err != nil { + return composeSpec{}, fmt.Errorf("parse compose file: %w", err) + } + if err := validateComposeSpec(&spec); err != nil { + return composeSpec{}, err + } + if err := interpolateComposeSpec(&spec, filepath.Dir(path)); err != nil { + return composeSpec{}, err + } + return spec, nil +} + +var composeNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +func validateComposeSpec(spec *composeSpec) error { + if spec.Version != 1 { + return fmt.Errorf("compose version must be 1") + } + if spec.Name == "" { + return fmt.Errorf("compose name is required") + } + if !composeNamePattern.MatchString(spec.Name) { + return fmt.Errorf("compose name must contain only lowercase letters, digits, and dashes") + } + if len(spec.Services) == 0 { + return fmt.Errorf("compose services must include at least one service") + } + + for name, service := range spec.Services { + if !composeNamePattern.MatchString(name) { + return fmt.Errorf("service %q must contain only lowercase letters, digits, and dashes", name) + } + instanceName := composeInstanceName(spec.Name, name) + 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) + } + for i, rule := range service.Ingress { + if rule.Hostname == "" { + return fmt.Errorf("service %q ingress %d hostname is required", name, i) + } + if rule.TargetPort <= 0 { + return fmt.Errorf("service %q ingress %d target_port must be positive", name, i) + } + if rule.HostPort < 0 { + return fmt.Errorf("service %q ingress %d host_port must be non-negative", name, i) + } + } + } + return nil +} + +var composeInterpolationPattern = regexp.MustCompile(`\$\{(file|env):([^}]+)\}`) + +func interpolateComposeSpec(spec *composeSpec, baseDir string) error { + for serviceName, service := range spec.Services { + for key, value := range service.Env { + resolved, err := interpolateComposeValue(value, baseDir) + if err != nil { + return fmt.Errorf("service %q env %s: %w", serviceName, key, err) + } + service.Env[key] = resolved + } + spec.Services[serviceName] = service + } + return nil +} + +func interpolateComposeValue(value, baseDir string) (string, error) { + var out strings.Builder + last := 0 + matches := composeInterpolationPattern.FindAllStringSubmatchIndex(value, -1) + for _, match := range matches { + out.WriteString(value[last:match[0]]) + kind := value[match[2]:match[3]] + arg := strings.TrimSpace(value[match[4]:match[5]]) + switch kind { + case "env": + envValue, ok := os.LookupEnv(arg) + if !ok { + return "", fmt.Errorf("environment variable %s is not set", arg) + } + out.WriteString(envValue) + case "file": + path := arg + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read file %s: %w", arg, err) + } + out.Write(data) + } + last = match[1] + } + out.WriteString(value[last:]) + return out.String(), nil +} + +func (r *Runner) Plan(ctx context.Context) (Plan, error) { + desiredInstances, desiredIngresses, images, err := r.desiredResources() + if err != nil { + return Plan{}, err + } + + var actions []Action + for _, image := range images { + action, err := r.planImage(ctx, image) + if err != nil { + return Plan{}, err + } + actions = append(actions, action) + } + + existingInstances, err := r.listComposeInstances(ctx) + if err != nil { + return Plan{}, err + } + allInstances, err := r.client.Instances.List(ctx, hypeman.InstanceListParams{}, r.opts...) + if err != nil { + return Plan{}, err + } + for _, inst := range desiredInstances { + actions = append(actions, planInstanceAction(inst, existingInstances, *allInstances)) + } + + existingIngresses, err := r.listComposeIngresses(ctx) + if err != nil { + return Plan{}, err + } + allIngresses, err := r.client.Ingresses.List(ctx, hypeman.IngressListParams{}, r.opts...) + if err != nil { + return Plan{}, err + } + for _, ingress := range desiredIngresses { + actions = append(actions, planIngressAction(ingress, existingIngresses, *allIngresses)) + } + + return Plan{ + Name: r.spec.Name, + File: r.file, + Actions: actions, + Summary: summarizeComposeActions(actions), + }, nil +} + +func (r *Runner) Up(ctx context.Context, opts UpOptions) (Plan, error) { + result, err := r.Plan(ctx) + if err != nil { + return Plan{}, err + } + if blockers := conflictBlockers(result.Actions); len(blockers) > 0 { + return result, fmt.Errorf("conflicts found:\n%s", strings.Join(blockers, "\n")) + } + if blockers := replacementBlockers(result.Actions, opts.Replace); len(blockers) > 0 { + return result, fmt.Errorf("replace required:\n%s\n\nRun again with --replace to recreate changed resources.", strings.Join(blockers, "\n")) + } + + for i := range result.Actions { + action := &result.Actions[i] + switch action.Action { + case "create": + if opts.Verbose { + fmt.Fprintf(os.Stderr, "[create] %s %s\n", action.Type, action.Name) + } + if err := r.applyCreate(ctx, action, opts); err != nil { + return result, err + } + case "replace": + if opts.Verbose { + fmt.Fprintf(os.Stderr, "[replace] %s %s\n", action.Type, action.Name) + } + if err := r.applyReplace(ctx, action, opts); err != nil { + return result, err + } + case "unchanged": + if opts.Verbose { + fmt.Fprintf(os.Stderr, "[skip] %s %s unchanged\n", action.Type, action.Name) + } + if action.Type == "image" { + if err := r.ensureImageReady(ctx, action.Name, opts.Verbose); err != nil { + return result, err + } + } + case "conflict": + return result, fmt.Errorf("%s %s already exists without compose ownership tags", action.Type, action.Name) + } + } + + return result, nil +} + +func (r *Runner) Down(ctx context.Context, verbose bool) (Plan, error) { + instances, err := r.listComposeInstances(ctx) + if err != nil { + return Plan{}, err + } + ingresses, err := r.listComposeIngresses(ctx) + if err != nil { + return Plan{}, err + } + + var actions []Action + for _, ing := range ingresses { + actions = append(actions, Action{ + Action: "delete", + Type: "ingress", + Name: ing.Name, + Service: ing.Tags[composeTagService], + Reason: "owned by compose file", + ingressID: ing.ID, + }) + } + for _, inst := range instances { + actions = append(actions, Action{ + Action: "delete", + Type: "instance", + Name: inst.Name, + Service: inst.Tags[composeTagService], + Reason: "owned by compose file", + instanceID: inst.ID, + }) + } + sortComposeActions(actions) + + result := Plan{ + Name: r.spec.Name, + File: r.file, + Actions: actions, + 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 { + result.Actions = append(result.Actions, Action{ + Action: "skip", + Type: "instance", + Name: composeInstanceName(r.spec.Name, serviceName), + Service: serviceName, + Reason: "not found", + }) + } + sortComposeActions(result.Actions) + result.Summary = summarizeComposeActions(result.Actions) + return result, nil + } + + for i := range actions { + action := &actions[i] + if verbose { + fmt.Fprintf(os.Stderr, "[delete] %s %s\n", action.Type, action.Name) + } + switch action.Type { + case "ingress": + if err := r.client.Ingresses.Delete(ctx, action.ingressID, r.opts...); err != nil && !isHTTPNotFound(err) { + return result, err + } + case "instance": + if err := r.client.Instances.Delete(ctx, action.instanceID, r.opts...); err != nil && !isHTTPNotFound(err) { + return result, err + } + } + } + + return result, nil +} + +func (r *Runner) applyCreate(ctx context.Context, action *Action, opts UpOptions) error { + switch action.Type { + case "image": + return r.ensureImageReady(ctx, action.Name, opts.Verbose) + case "instance": + var inst hypeman.Instance + if err := r.client.Post(ctx, "instances", action.instanceInput, &inst, r.opts...); err != nil { + return err + } + action.instanceID = inst.ID + if opts.Wait { + return r.waitForInstanceRunning(ctx, inst.ID, opts.WaitTimeout, opts.Verbose) + } + case "ingress": + ing, err := r.client.Ingresses.New(ctx, action.ingressInput, r.opts...) + if err != nil { + return err + } + action.ingressID = ing.ID + } + return nil +} + +func (r *Runner) applyReplace(ctx context.Context, action *Action, opts UpOptions) error { + switch action.Type { + case "instance": + if action.instanceID != "" { + if err := r.client.Instances.Delete(ctx, action.instanceID, r.opts...); err != nil && !isHTTPNotFound(err) { + return err + } + } + case "ingress": + if action.ingressID != "" { + if err := r.client.Ingresses.Delete(ctx, action.ingressID, r.opts...); err != nil && !isHTTPNotFound(err) { + return err + } + } + } + createAction := *action + createAction.Action = "create" + if err := r.applyCreate(ctx, &createAction, opts); err != nil { + return err + } + action.instanceID = createAction.instanceID + action.ingressID = createAction.ingressID + return nil +} + +func (r *Runner) ensureImageReady(ctx context.Context, image string, verbose bool) error { + img, err := r.client.Images.Get(ctx, url.PathEscape(image), r.opts...) + if err != nil { + if !isHTTPNotFound(err) { + return fmt.Errorf("check image %s: %w", image, err) + } + img, err = r.client.Images.New(ctx, hypeman.ImageNewParams{Name: image}, r.opts...) + if err != nil { + return fmt.Errorf("create image %s: %w", image, err) + } + } + if verbose && img.Status != hypeman.ImageStatusReady { + fmt.Fprintf(os.Stderr, "[wait] image %s ready\n", image) + } + return waitForImageReady(ctx, &r.client, img) +} + +func waitForImageReady(ctx context.Context, client *hypeman.Client, img *hypeman.Image) error { + if img.Status == hypeman.ImageStatusReady { + return nil + } + if img.Status == hypeman.ImageStatusFailed { + if img.Error != "" { + return fmt.Errorf("image build failed: %s", img.Error) + } + return fmt.Errorf("image build failed") + } + + ticker := time.NewTicker(300 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + updated, err := client.Images.Get(ctx, url.PathEscape(img.Name)) + if err != nil { + return fmt.Errorf("failed to check image status: %w", err) + } + switch updated.Status { + case hypeman.ImageStatusReady: + return nil + case hypeman.ImageStatusFailed: + if updated.Error != "" { + return fmt.Errorf("image build failed: %s", updated.Error) + } + return fmt.Errorf("image build failed") + } + } + } +} + +func (r *Runner) waitForInstanceRunning(ctx context.Context, instanceID, timeout string, verbose bool) error { + if verbose { + fmt.Fprintf(os.Stderr, "[wait] instance %s running\n", instanceID) + } + params := hypeman.InstanceWaitParams{ + State: hypeman.InstanceWaitParamsStateRunning, + } + if timeout != "" { + params.Timeout = hypeman.Opt(timeout) + } + resp, err := r.client.Instances.Wait(ctx, instanceID, params, r.opts...) + if err != nil { + return err + } + if resp.TimedOut { + return fmt.Errorf("timed out waiting for instance %s to reach Running", instanceID) + } + return nil +} + +func (r *Runner) desiredResources() ([]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{}{} + } + sort.Strings(serviceNames) + + images := make([]string, 0, len(imageSet)) + for image := range imageSet { + images = append(images, image) + } + sort.Strings(images) + + instances := make([]desiredInstance, 0, len(serviceNames)) + var ingresses []desiredIngress + for _, serviceName := range serviceNames { + service := r.spec.Services[serviceName] + instanceName := composeInstanceName(r.spec.Name, serviceName) + instanceInput := buildComposeInstanceInput(instanceName, service) + instanceHash, err := shortHash(instanceInput) + if err != nil { + return nil, nil, nil, err + } + instanceInput["tags"] = composeTags(r.spec.Name, serviceName, composeResourceInstance, instanceHash) + instances = append(instances, desiredInstance{ + Name: instanceName, + Service: serviceName, + Hash: instanceHash, + Input: instanceInput, + }) + + for i, ingressSpec := range service.Ingress { + ingressName := composeIngressName(r.spec.Name, serviceName, i) + ingressInput := buildComposeIngressInput(instanceName, ingressName, ingressSpec) + ingressHash, err := shortHash(ingressInput) + if err != nil { + return nil, nil, nil, err + } + ingressInput.Tags = composeTags(r.spec.Name, serviceName, composeResourceIngress, ingressHash) + ingresses = append(ingresses, desiredIngress{ + Name: ingressName, + Service: serviceName, + Hash: ingressHash, + Input: ingressInput, + }) + } + } + return instances, ingresses, images, nil +} + +func buildComposeInstanceInput(instanceName string, service composeServiceSpec) map[string]any { + input := map[string]any{ + "name": instanceName, + "image": service.Image, + } + if len(service.Entrypoint) > 0 { + input["entrypoint"] = service.Entrypoint + } + if len(service.Cmd) > 0 { + input["cmd"] = service.Cmd + } + if len(service.Env) > 0 { + input["env"] = service.Env + } + if service.Resources.Vcpus > 0 { + input["vcpus"] = service.Resources.Vcpus + } + if service.Resources.Memory != "" { + input["size"] = service.Resources.Memory + } + if service.Resources.OverlaySize != "" { + input["overlay_size"] = service.Resources.OverlaySize + } + if service.Resources.HotplugSize != "" { + input["hotplug_size"] = service.Resources.HotplugSize + } + if service.Resources.DiskIOBps != "" { + input["disk_io_bps"] = service.Resources.DiskIOBps + } + if service.Resources.BandwidthDownload != "" || service.Resources.BandwidthUpload != "" { + network := map[string]any{} + if service.Resources.BandwidthDownload != "" { + network["bandwidth_download"] = service.Resources.BandwidthDownload + } + if service.Resources.BandwidthUpload != "" { + network["bandwidth_upload"] = service.Resources.BandwidthUpload + } + input["network"] = network + } + if service.Restart != nil { + input["restart_policy"] = buildComposeRestartPayload(service.Restart) + } + if service.Health != nil { + input["health_check"] = service.Health + } + return input +} + +func buildComposeRestartPayload(restart *composeRestartSpec) map[string]any { + payload := map[string]any{} + if restart.Policy != "" { + payload["policy"] = strings.ReplaceAll(restart.Policy, "-", "_") + } + if restart.Backoff != "" { + payload["backoff"] = restart.Backoff + } + if restart.MaxAttempts > 0 { + payload["max_attempts"] = restart.MaxAttempts + } + if restart.StableAfter != "" { + payload["stable_after"] = restart.StableAfter + } + return payload +} + +func buildComposeIngressInput(instanceName, ingressName string, spec composeIngressRuleSpec) hypeman.IngressNewParams { + hostPort := spec.HostPort + if hostPort == 0 { + hostPort = 80 + } + return hypeman.IngressNewParams{ + Name: ingressName, + Rules: []hypeman.IngressRuleParam{ + { + Match: hypeman.IngressMatchParam{ + Hostname: spec.Hostname, + Port: hypeman.Int(int64(hostPort)), + }, + Target: hypeman.IngressTargetParam{ + Instance: instanceName, + Port: int64(spec.TargetPort), + }, + Tls: hypeman.Bool(spec.TLS), + RedirectHTTP: hypeman.Bool(spec.RedirectHTTP), + }, + }, + } +} + +func (r *Runner) planImage(ctx context.Context, image string) (Action, error) { + _, err := r.client.Images.Get(ctx, url.PathEscape(image), r.opts...) + if err == nil { + return Action{Action: "unchanged", Type: "image", Name: image, Reason: "already exists"}, nil + } + if isHTTPNotFound(err) { + return Action{Action: "create", Type: "image", Name: image, Reason: "not present"}, nil + } + return Action{}, fmt.Errorf("check image %s: %w", image, err) +} + +func planInstanceAction(desired desiredInstance, owned []hypeman.Instance, all []hypeman.Instance) Action { + action := Action{ + Type: "instance", + Name: desired.Name, + Service: desired.Service, + instanceInput: desired.Input, + } + for _, inst := range owned { + if inst.Name != desired.Name { + continue + } + action.instanceID = inst.ID + if inst.Tags[composeTagHash] == desired.Hash { + action.Action = "unchanged" + action.Reason = "hash matches" + return action + } + action.Action = "replace" + if inst.Tags[composeTagHash] == "" { + action.Reason = "missing compose hash" + } else { + action.Reason = "rendered spec changed" + } + return action + } + for _, inst := range all { + if inst.Name == desired.Name { + action.Action = "conflict" + action.Reason = "name exists without compose ownership" + action.instanceID = inst.ID + return action + } + } + action.Action = "create" + action.Reason = "missing" + return action +} + +func planIngressAction(desired desiredIngress, owned []hypeman.Ingress, all []hypeman.Ingress) Action { + action := Action{ + Type: "ingress", + Name: desired.Name, + Service: desired.Service, + ingressInput: desired.Input, + } + for _, ing := range owned { + if ing.Name != desired.Name { + continue + } + action.ingressID = ing.ID + if ing.Tags[composeTagHash] == desired.Hash { + action.Action = "unchanged" + action.Reason = "hash matches" + return action + } + action.Action = "replace" + if ing.Tags[composeTagHash] == "" { + action.Reason = "missing compose hash" + } else { + action.Reason = "rendered spec changed" + } + return action + } + for _, ing := range all { + if ing.Name == desired.Name { + action.Action = "conflict" + action.Reason = "name exists without compose ownership" + action.ingressID = ing.ID + return action + } + } + action.Action = "create" + action.Reason = "missing" + return action +} + +func (r *Runner) listComposeInstances(ctx context.Context) ([]hypeman.Instance, error) { + instances, err := r.client.Instances.List(ctx, hypeman.InstanceListParams{ + Tags: map[string]string{composeTagName: r.spec.Name}, + }, r.opts...) + if err != nil { + return nil, err + } + return *instances, nil +} + +func (r *Runner) listComposeIngresses(ctx context.Context) ([]hypeman.Ingress, error) { + ingresses, err := r.client.Ingresses.List(ctx, hypeman.IngressListParams{ + Tags: map[string]string{composeTagName: r.spec.Name}, + }, r.opts...) + if err != nil { + return nil, err + } + return *ingresses, nil +} + +func replacementBlockers(actions []Action, replace bool) []string { + if replace { + return nil + } + var blockers []string + for _, action := range actions { + if action.Action == "replace" { + blockers = append(blockers, fmt.Sprintf(" %s %s changed: %s", action.Type, action.Name, action.Reason)) + } + } + return blockers +} + +func conflictBlockers(actions []Action) []string { + var blockers []string + for _, action := range actions { + if action.Action == "conflict" { + blockers = append(blockers, fmt.Sprintf(" %s %s: %s", action.Type, action.Name, action.Reason)) + } + } + return blockers +} + +func summarizeComposeActions(actions []Action) Summary { + var summary Summary + for _, action := range actions { + switch action.Action { + case "create": + summary.Create++ + case "replace": + summary.Replace++ + case "delete": + summary.Delete++ + case "unchanged": + summary.Unchanged++ + case "skip": + summary.Skip++ + case "conflict": + summary.Conflict++ + } + } + return summary +} + +func composeInstanceName(composeName, serviceName string) string { + return composeName + "-" + serviceName +} + +func composeIngressName(composeName, serviceName string, index int) string { + return fmt.Sprintf("%s-%s-%d", composeName, serviceName, index) +} + +func composeTags(composeName, serviceName, resource, hash string) map[string]string { + return map[string]string{ + composeTagName: composeName, + composeTagService: serviceName, + composeTagResource: resource, + composeTagHash: hash, + } +} + +func shortHash(v any) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:])[:12], nil +} + +func isHTTPNotFound(err error) bool { + apiErr, ok := err.(*hypeman.Error) + return ok && apiErr.Response != nil && apiErr.Response.StatusCode == 404 +} + +func sortComposeActions(actions []Action) { + order := map[string]int{ + "image": 0, + "ingress": 1, + "instance": 2, + } + sort.SliceStable(actions, func(i, j int) bool { + if order[actions[i].Type] != order[actions[j].Type] { + return order[actions[i].Type] < order[actions[j].Type] + } + return actions[i].Name < actions[j].Name + }) +} diff --git a/pkg/cmd/composecmd_test.go b/lib/compose/compose_test.go similarity index 98% rename from pkg/cmd/composecmd_test.go rename to lib/compose/compose_test.go index 9f223f1..07bf508 100644 --- a/pkg/cmd/composecmd_test.go +++ b/lib/compose/compose_test.go @@ -1,4 +1,4 @@ -package cmd +package compose import ( "os" @@ -83,7 +83,7 @@ func TestBuildComposeInstanceInputIncludesFuturePolicyFields(t *testing.T) { } func TestDesiredResourcesUseDeterministicNamesAndTags(t *testing.T) { - runner := composeRunner{ + runner := Runner{ spec: composeSpec{ Version: 1, Name: "hypeship-otel", @@ -127,7 +127,7 @@ func TestValidateComposeSpecRejectsInvalidNames(t *testing.T) { } func TestConflictBlockers(t *testing.T) { - blockers := conflictBlockers([]composeAction{ + blockers := conflictBlockers([]Action{ {Action: "create", Type: "image", Name: "alpine:latest"}, {Action: "conflict", Type: "instance", Name: "app-api", Reason: "name exists without compose ownership"}, }) diff --git a/pkg/cmd/composecmd.go b/pkg/cmd/composecmd.go index f557dec..8ba3219 100644 --- a/pkg/cmd/composecmd.go +++ b/pkg/cmd/composecmd.go @@ -2,32 +2,16 @@ package cmd import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" - "net/url" "os" - "path/filepath" - "regexp" - "sort" "strings" + "github.com/kernel/hypeman-cli/lib/compose" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" - "gopkg.in/yaml.v3" -) - -const ( - composeTagName = "hypeman.compose.name" - composeTagService = "hypeman.compose.service" - composeTagResource = "hypeman.compose.resource" - composeTagHash = "hypeman.compose.hash" - - composeResourceInstance = "instance" - composeResourceIngress = "ingress" ) var composeCmd = cli.Command{ @@ -50,7 +34,7 @@ var composePlanCmd = cli.Command{ if err != nil { return err } - result, err := runner.plan(ctx) + result, err := runner.Plan(ctx) if err != nil { return err } @@ -83,7 +67,7 @@ var composeUpCmd = cli.Command{ if err != nil { return err } - result, err := runner.up(ctx, composeUpOptions{ + result, err := runner.Up(ctx, compose.UpOptions{ Replace: cmd.Bool("replace"), Wait: cmd.Bool("wait"), WaitTimeout: cmd.String("wait-timeout"), @@ -110,7 +94,7 @@ var composeDownCmd = cli.Command{ if err != nil { return err } - result, err := runner.down(ctx, cmd.Root().String("format") == "auto") + result, err := runner.Down(ctx, cmd.Root().String("format") == "auto") if err != nil { return err } @@ -134,818 +118,16 @@ func composeFileFlags() []cli.Flag { } } -type composeRunner struct { - file string - spec composeSpec - client hypeman.Client - opts []option.RequestOption -} - -type composeUpOptions struct { - Replace bool - Wait bool - WaitTimeout string - Verbose bool -} - -type composeSpec struct { - Version int `json:"version" yaml:"version"` - Name string `json:"name" yaml:"name"` - Services map[string]composeServiceSpec `json:"services" yaml:"services"` -} - -type composeServiceSpec struct { - Image string `json:"image" yaml:"image"` - Entrypoint []string `json:"entrypoint,omitempty" yaml:"entrypoint"` - Cmd []string `json:"cmd,omitempty" yaml:"cmd"` - Env map[string]string `json:"env,omitempty" yaml:"env"` - Resources composeResourcesSpec `json:"resources,omitempty" yaml:"resources"` - Restart *composeRestartSpec `json:"restart,omitempty" yaml:"restart"` - Health *composeCheckSpec `json:"healthcheck,omitempty" yaml:"healthcheck"` - Ingress []composeIngressRuleSpec `json:"ingress,omitempty" yaml:"ingress"` -} - -type composeResourcesSpec struct { - Vcpus int `json:"vcpus,omitempty" yaml:"vcpus"` - Memory string `json:"memory,omitempty" yaml:"memory"` - OverlaySize string `json:"overlay_size,omitempty" yaml:"overlay_size"` - HotplugSize string `json:"hotplug_size,omitempty" yaml:"hotplug_size"` - DiskIOBps string `json:"disk_io_bps,omitempty" yaml:"disk_io_bps"` - BandwidthUpload string `json:"bandwidth_upload,omitempty" yaml:"bandwidth_upload"` - BandwidthDownload string `json:"bandwidth_download,omitempty" yaml:"bandwidth_download"` -} - -type composeRestartSpec struct { - Policy string `json:"policy,omitempty" yaml:"policy"` - Backoff string `json:"backoff,omitempty" yaml:"backoff"` - MaxAttempts int `json:"max_attempts,omitempty" yaml:"max_attempts"` - StableAfter string `json:"stable_after,omitempty" yaml:"stable_after"` -} - -type composeCheckSpec struct { - HTTP *composeHTTPCheckSpec `json:"http,omitempty" yaml:"http"` - TCP *composeTCPCheckSpec `json:"tcp,omitempty" yaml:"tcp"` - Exec *composeExecCheckSpec `json:"exec,omitempty" yaml:"exec"` - Type string `json:"type,omitempty" yaml:"type"` - Interval string `json:"interval,omitempty" yaml:"interval"` - Timeout string `json:"timeout,omitempty" yaml:"timeout"` - StartPeriod string `json:"start_period,omitempty" yaml:"start_period"` - FailureThreshold int `json:"failure_threshold,omitempty" yaml:"failure_threshold"` - SuccessThreshold int `json:"success_threshold,omitempty" yaml:"success_threshold"` -} - -type composeHTTPCheckSpec struct { - Port int `json:"port,omitempty" yaml:"port"` - Path string `json:"path,omitempty" yaml:"path"` - Scheme string `json:"scheme,omitempty" yaml:"scheme"` - ExpectedStatus int `json:"expected_status,omitempty" yaml:"expected_status"` -} - -type composeTCPCheckSpec struct { - Port int `json:"port,omitempty" yaml:"port"` -} - -type composeExecCheckSpec struct { - Command []string `json:"command,omitempty" yaml:"command"` - WorkingDir string `json:"working_dir,omitempty" yaml:"working_dir"` -} - -type composeIngressRuleSpec struct { - Hostname string `json:"hostname" yaml:"hostname"` - HostPort int `json:"host_port,omitempty" yaml:"host_port"` - TargetPort int `json:"target_port" yaml:"target_port"` - TLS bool `json:"tls,omitempty" yaml:"tls"` - RedirectHTTP bool `json:"redirect_http,omitempty" yaml:"redirect_http"` -} - -type composePlan struct { - Name string `json:"name"` - File string `json:"file"` - Actions []composeAction `json:"actions"` - Summary composeSummary `json:"summary"` -} - -type composeSummary struct { - Create int `json:"create"` - Replace int `json:"replace"` - Delete int `json:"delete"` - Unchanged int `json:"unchanged"` - Skip int `json:"skip"` - Conflict int `json:"conflict"` -} - -type composeAction struct { - Action string `json:"action"` - Type string `json:"type"` - Name string `json:"name"` - Service string `json:"service,omitempty"` - Reason string `json:"reason"` - - instanceID string - ingressID string - instanceInput map[string]any - ingressInput hypeman.IngressNewParams -} - -type desiredInstance struct { - Name string - Service string - Hash string - Input map[string]any -} - -type desiredIngress struct { - Name string - Service string - Hash string - Input hypeman.IngressNewParams -} - -func newComposeRunner(cmd *cli.Command) (*composeRunner, error) { - file := cmd.String("file") - spec, err := loadComposeSpec(file) - if err != nil { - return nil, err - } +func newComposeRunner(cmd *cli.Command) (*compose.Runner, error) { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) var opts []option.RequestOption if cmd.Root().Bool("debug") { opts = append(opts, debugMiddlewareOption) } - return &composeRunner{ - file: file, - spec: spec, - client: client, - opts: opts, - }, nil -} - -func loadComposeSpec(path string) (composeSpec, error) { - data, err := os.ReadFile(path) - if err != nil { - return composeSpec{}, fmt.Errorf("read compose file: %w", err) - } - var spec composeSpec - if err := yaml.Unmarshal(data, &spec); err != nil { - return composeSpec{}, fmt.Errorf("parse compose file: %w", err) - } - if err := validateComposeSpec(&spec); err != nil { - return composeSpec{}, err - } - if err := interpolateComposeSpec(&spec, filepath.Dir(path)); err != nil { - return composeSpec{}, err - } - return spec, nil -} - -var composeNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) - -func validateComposeSpec(spec *composeSpec) error { - if spec.Version != 1 { - return fmt.Errorf("compose version must be 1") - } - if spec.Name == "" { - return fmt.Errorf("compose name is required") - } - if !composeNamePattern.MatchString(spec.Name) { - return fmt.Errorf("compose name must contain only lowercase letters, digits, and dashes") - } - if len(spec.Services) == 0 { - return fmt.Errorf("compose services must include at least one service") - } - - for name, service := range spec.Services { - if !composeNamePattern.MatchString(name) { - return fmt.Errorf("service %q must contain only lowercase letters, digits, and dashes", name) - } - instanceName := composeInstanceName(spec.Name, name) - 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) - } - for i, rule := range service.Ingress { - if rule.Hostname == "" { - return fmt.Errorf("service %q ingress %d hostname is required", name, i) - } - if rule.TargetPort <= 0 { - return fmt.Errorf("service %q ingress %d target_port must be positive", name, i) - } - if rule.HostPort < 0 { - return fmt.Errorf("service %q ingress %d host_port must be non-negative", name, i) - } - } - } - return nil -} - -var composeInterpolationPattern = regexp.MustCompile(`\$\{(file|env):([^}]+)\}`) - -func interpolateComposeSpec(spec *composeSpec, baseDir string) error { - for serviceName, service := range spec.Services { - for key, value := range service.Env { - resolved, err := interpolateComposeValue(value, baseDir) - if err != nil { - return fmt.Errorf("service %q env %s: %w", serviceName, key, err) - } - service.Env[key] = resolved - } - spec.Services[serviceName] = service - } - return nil -} - -func interpolateComposeValue(value, baseDir string) (string, error) { - var out strings.Builder - last := 0 - matches := composeInterpolationPattern.FindAllStringSubmatchIndex(value, -1) - for _, match := range matches { - out.WriteString(value[last:match[0]]) - kind := value[match[2]:match[3]] - arg := strings.TrimSpace(value[match[4]:match[5]]) - switch kind { - case "env": - envValue, ok := os.LookupEnv(arg) - if !ok { - return "", fmt.Errorf("environment variable %s is not set", arg) - } - out.WriteString(envValue) - case "file": - path := arg - if !filepath.IsAbs(path) { - path = filepath.Join(baseDir, path) - } - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read file %s: %w", arg, err) - } - out.Write(data) - } - last = match[1] - } - out.WriteString(value[last:]) - return out.String(), nil -} - -func (r *composeRunner) plan(ctx context.Context) (composePlan, error) { - desiredInstances, desiredIngresses, images, err := r.desiredResources() - if err != nil { - return composePlan{}, err - } - - var actions []composeAction - for _, image := range images { - action, err := r.planImage(ctx, image) - if err != nil { - return composePlan{}, err - } - actions = append(actions, action) - } - - existingInstances, err := r.listComposeInstances(ctx) - if err != nil { - return composePlan{}, err - } - allInstances, err := r.client.Instances.List(ctx, hypeman.InstanceListParams{}, r.opts...) - if err != nil { - return composePlan{}, err - } - for _, inst := range desiredInstances { - actions = append(actions, planInstanceAction(inst, existingInstances, *allInstances)) - } - - existingIngresses, err := r.listComposeIngresses(ctx) - if err != nil { - return composePlan{}, err - } - allIngresses, err := r.client.Ingresses.List(ctx, hypeman.IngressListParams{}, r.opts...) - if err != nil { - return composePlan{}, err - } - for _, ingress := range desiredIngresses { - actions = append(actions, planIngressAction(ingress, existingIngresses, *allIngresses)) - } - - return composePlan{ - Name: r.spec.Name, - File: r.file, - Actions: actions, - Summary: summarizeComposeActions(actions), - }, nil -} - -func (r *composeRunner) up(ctx context.Context, opts composeUpOptions) (composePlan, error) { - result, err := r.plan(ctx) - if err != nil { - return composePlan{}, err - } - if blockers := conflictBlockers(result.Actions); len(blockers) > 0 { - return result, fmt.Errorf("conflicts found:\n%s", strings.Join(blockers, "\n")) - } - if blockers := replacementBlockers(result.Actions, opts.Replace); len(blockers) > 0 { - return result, fmt.Errorf("replace required:\n%s\n\nRun again with --replace to recreate changed resources.", strings.Join(blockers, "\n")) - } - - for i := range result.Actions { - action := &result.Actions[i] - switch action.Action { - case "create": - if opts.Verbose { - fmt.Fprintf(os.Stderr, "[create] %s %s\n", action.Type, action.Name) - } - if err := r.applyCreate(ctx, action, opts); err != nil { - return result, err - } - case "replace": - if opts.Verbose { - fmt.Fprintf(os.Stderr, "[replace] %s %s\n", action.Type, action.Name) - } - if err := r.applyReplace(ctx, action, opts); err != nil { - return result, err - } - case "unchanged": - if opts.Verbose { - fmt.Fprintf(os.Stderr, "[skip] %s %s unchanged\n", action.Type, action.Name) - } - if action.Type == "image" { - if err := r.ensureImageReady(ctx, action.Name, opts.Verbose); err != nil { - return result, err - } - } - case "conflict": - return result, fmt.Errorf("%s %s already exists without compose ownership tags", action.Type, action.Name) - } - } - - return result, nil -} - -func (r *composeRunner) down(ctx context.Context, verbose bool) (composePlan, error) { - instances, err := r.listComposeInstances(ctx) - if err != nil { - return composePlan{}, err - } - ingresses, err := r.listComposeIngresses(ctx) - if err != nil { - return composePlan{}, err - } - - var actions []composeAction - for _, ing := range ingresses { - actions = append(actions, composeAction{ - Action: "delete", - Type: "ingress", - Name: ing.Name, - Service: ing.Tags[composeTagService], - Reason: "owned by compose file", - ingressID: ing.ID, - }) - } - for _, inst := range instances { - actions = append(actions, composeAction{ - Action: "delete", - Type: "instance", - Name: inst.Name, - Service: inst.Tags[composeTagService], - Reason: "owned by compose file", - instanceID: inst.ID, - }) - } - sortComposeActions(actions) - - result := composePlan{ - Name: r.spec.Name, - File: r.file, - Actions: actions, - Summary: summarizeComposeActions(actions), - } - if len(actions) == 0 { - _, desiredIngresses, _, err := r.desiredResources() - if err != nil { - return composePlan{}, err - } - for _, ingress := range desiredIngresses { - result.Actions = append(result.Actions, composeAction{ - Action: "skip", - Type: "ingress", - Name: ingress.Name, - Service: ingress.Service, - Reason: "not found", - }) - } - for serviceName := range r.spec.Services { - result.Actions = append(result.Actions, composeAction{ - Action: "skip", - Type: "instance", - Name: composeInstanceName(r.spec.Name, serviceName), - Service: serviceName, - Reason: "not found", - }) - } - sortComposeActions(result.Actions) - result.Summary = summarizeComposeActions(result.Actions) - return result, nil - } - - for i := range actions { - action := &actions[i] - if verbose { - fmt.Fprintf(os.Stderr, "[delete] %s %s\n", action.Type, action.Name) - } - switch action.Type { - case "ingress": - if err := r.client.Ingresses.Delete(ctx, action.ingressID, r.opts...); err != nil && !isHTTPNotFound(err) { - return result, err - } - case "instance": - if err := r.client.Instances.Delete(ctx, action.instanceID, r.opts...); err != nil && !isHTTPNotFound(err) { - return result, err - } - } - } - - return result, nil -} - -func (r *composeRunner) applyCreate(ctx context.Context, action *composeAction, opts composeUpOptions) error { - switch action.Type { - case "image": - return r.ensureImageReady(ctx, action.Name, opts.Verbose) - case "instance": - var inst hypeman.Instance - if err := r.client.Post(ctx, "instances", action.instanceInput, &inst, r.opts...); err != nil { - return err - } - action.instanceID = inst.ID - if opts.Wait { - return r.waitForInstanceRunning(ctx, inst.ID, opts.WaitTimeout, opts.Verbose) - } - case "ingress": - ing, err := r.client.Ingresses.New(ctx, action.ingressInput, r.opts...) - if err != nil { - return err - } - action.ingressID = ing.ID - } - return nil + return compose.NewRunner(cmd.String("file"), client, opts...) } -func (r *composeRunner) applyReplace(ctx context.Context, action *composeAction, opts composeUpOptions) error { - switch action.Type { - case "instance": - if action.instanceID != "" { - if err := r.client.Instances.Delete(ctx, action.instanceID, r.opts...); err != nil && !isHTTPNotFound(err) { - return err - } - } - case "ingress": - if action.ingressID != "" { - if err := r.client.Ingresses.Delete(ctx, action.ingressID, r.opts...); err != nil && !isHTTPNotFound(err) { - return err - } - } - } - createAction := *action - createAction.Action = "create" - if err := r.applyCreate(ctx, &createAction, opts); err != nil { - return err - } - action.instanceID = createAction.instanceID - action.ingressID = createAction.ingressID - return nil -} - -func (r *composeRunner) ensureImageReady(ctx context.Context, image string, verbose bool) error { - img, err := r.client.Images.Get(ctx, url.PathEscape(image), r.opts...) - if err != nil { - if !isHTTPNotFound(err) { - return fmt.Errorf("check image %s: %w", image, err) - } - img, err = r.client.Images.New(ctx, hypeman.ImageNewParams{Name: image}, r.opts...) - if err != nil { - return fmt.Errorf("create image %s: %w", image, err) - } - } - if verbose && img.Status != hypeman.ImageStatusReady { - fmt.Fprintf(os.Stderr, "[wait] image %s ready\n", image) - } - return waitForImageReady(ctx, &r.client, img) -} - -func (r *composeRunner) waitForInstanceRunning(ctx context.Context, instanceID, timeout string, verbose bool) error { - if verbose { - fmt.Fprintf(os.Stderr, "[wait] instance %s running\n", instanceID) - } - params := hypeman.InstanceWaitParams{ - State: hypeman.InstanceWaitParamsStateRunning, - } - if timeout != "" { - params.Timeout = hypeman.Opt(timeout) - } - resp, err := r.client.Instances.Wait(ctx, instanceID, params, r.opts...) - if err != nil { - return err - } - if resp.TimedOut { - return fmt.Errorf("timed out waiting for instance %s to reach Running", instanceID) - } - return nil -} - -func (r *composeRunner) desiredResources() ([]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{}{} - } - sort.Strings(serviceNames) - - images := make([]string, 0, len(imageSet)) - for image := range imageSet { - images = append(images, image) - } - sort.Strings(images) - - instances := make([]desiredInstance, 0, len(serviceNames)) - var ingresses []desiredIngress - for _, serviceName := range serviceNames { - service := r.spec.Services[serviceName] - instanceName := composeInstanceName(r.spec.Name, serviceName) - instanceInput := buildComposeInstanceInput(instanceName, service) - instanceHash, err := shortHash(instanceInput) - if err != nil { - return nil, nil, nil, err - } - instanceInput["tags"] = composeTags(r.spec.Name, serviceName, composeResourceInstance, instanceHash) - instances = append(instances, desiredInstance{ - Name: instanceName, - Service: serviceName, - Hash: instanceHash, - Input: instanceInput, - }) - - for i, ingressSpec := range service.Ingress { - ingressName := composeIngressName(r.spec.Name, serviceName, i) - ingressInput := buildComposeIngressInput(instanceName, ingressName, ingressSpec) - ingressHash, err := shortHash(ingressInput) - if err != nil { - return nil, nil, nil, err - } - ingressInput.Tags = composeTags(r.spec.Name, serviceName, composeResourceIngress, ingressHash) - ingresses = append(ingresses, desiredIngress{ - Name: ingressName, - Service: serviceName, - Hash: ingressHash, - Input: ingressInput, - }) - } - } - return instances, ingresses, images, nil -} - -func buildComposeInstanceInput(instanceName string, service composeServiceSpec) map[string]any { - input := map[string]any{ - "name": instanceName, - "image": service.Image, - } - if len(service.Entrypoint) > 0 { - input["entrypoint"] = service.Entrypoint - } - if len(service.Cmd) > 0 { - input["cmd"] = service.Cmd - } - if len(service.Env) > 0 { - input["env"] = service.Env - } - if service.Resources.Vcpus > 0 { - input["vcpus"] = service.Resources.Vcpus - } - if service.Resources.Memory != "" { - input["size"] = service.Resources.Memory - } - if service.Resources.OverlaySize != "" { - input["overlay_size"] = service.Resources.OverlaySize - } - if service.Resources.HotplugSize != "" { - input["hotplug_size"] = service.Resources.HotplugSize - } - if service.Resources.DiskIOBps != "" { - input["disk_io_bps"] = service.Resources.DiskIOBps - } - if service.Resources.BandwidthDownload != "" || service.Resources.BandwidthUpload != "" { - network := map[string]any{} - if service.Resources.BandwidthDownload != "" { - network["bandwidth_download"] = service.Resources.BandwidthDownload - } - if service.Resources.BandwidthUpload != "" { - network["bandwidth_upload"] = service.Resources.BandwidthUpload - } - input["network"] = network - } - if service.Restart != nil { - input["restart_policy"] = buildComposeRestartPayload(service.Restart) - } - if service.Health != nil { - input["health_check"] = service.Health - } - return input -} - -func buildComposeRestartPayload(restart *composeRestartSpec) map[string]any { - payload := map[string]any{} - if restart.Policy != "" { - payload["policy"] = strings.ReplaceAll(restart.Policy, "-", "_") - } - if restart.Backoff != "" { - payload["backoff"] = restart.Backoff - } - if restart.MaxAttempts > 0 { - payload["max_attempts"] = restart.MaxAttempts - } - if restart.StableAfter != "" { - payload["stable_after"] = restart.StableAfter - } - return payload -} - -func buildComposeIngressInput(instanceName, ingressName string, spec composeIngressRuleSpec) hypeman.IngressNewParams { - hostPort := spec.HostPort - if hostPort == 0 { - hostPort = 80 - } - return hypeman.IngressNewParams{ - Name: ingressName, - Rules: []hypeman.IngressRuleParam{ - { - Match: hypeman.IngressMatchParam{ - Hostname: spec.Hostname, - Port: hypeman.Int(int64(hostPort)), - }, - Target: hypeman.IngressTargetParam{ - Instance: instanceName, - Port: int64(spec.TargetPort), - }, - Tls: hypeman.Bool(spec.TLS), - RedirectHTTP: hypeman.Bool(spec.RedirectHTTP), - }, - }, - } -} - -func (r *composeRunner) planImage(ctx context.Context, image string) (composeAction, error) { - _, err := r.client.Images.Get(ctx, url.PathEscape(image), r.opts...) - if err == nil { - return composeAction{Action: "unchanged", Type: "image", Name: image, Reason: "already exists"}, nil - } - if isHTTPNotFound(err) { - return composeAction{Action: "create", Type: "image", Name: image, Reason: "not present"}, nil - } - return composeAction{}, fmt.Errorf("check image %s: %w", image, err) -} - -func planInstanceAction(desired desiredInstance, owned []hypeman.Instance, all []hypeman.Instance) composeAction { - action := composeAction{ - Type: "instance", - Name: desired.Name, - Service: desired.Service, - instanceInput: desired.Input, - } - for _, inst := range owned { - if inst.Name != desired.Name { - continue - } - action.instanceID = inst.ID - if inst.Tags[composeTagHash] == desired.Hash { - action.Action = "unchanged" - action.Reason = "hash matches" - return action - } - action.Action = "replace" - if inst.Tags[composeTagHash] == "" { - action.Reason = "missing compose hash" - } else { - action.Reason = "rendered spec changed" - } - return action - } - for _, inst := range all { - if inst.Name == desired.Name { - action.Action = "conflict" - action.Reason = "name exists without compose ownership" - action.instanceID = inst.ID - return action - } - } - action.Action = "create" - action.Reason = "missing" - return action -} - -func planIngressAction(desired desiredIngress, owned []hypeman.Ingress, all []hypeman.Ingress) composeAction { - action := composeAction{ - Type: "ingress", - Name: desired.Name, - Service: desired.Service, - ingressInput: desired.Input, - } - for _, ing := range owned { - if ing.Name != desired.Name { - continue - } - action.ingressID = ing.ID - if ing.Tags[composeTagHash] == desired.Hash { - action.Action = "unchanged" - action.Reason = "hash matches" - return action - } - action.Action = "replace" - if ing.Tags[composeTagHash] == "" { - action.Reason = "missing compose hash" - } else { - action.Reason = "rendered spec changed" - } - return action - } - for _, ing := range all { - if ing.Name == desired.Name { - action.Action = "conflict" - action.Reason = "name exists without compose ownership" - action.ingressID = ing.ID - return action - } - } - action.Action = "create" - action.Reason = "missing" - return action -} - -func (r *composeRunner) listComposeInstances(ctx context.Context) ([]hypeman.Instance, error) { - instances, err := r.client.Instances.List(ctx, hypeman.InstanceListParams{ - Tags: map[string]string{composeTagName: r.spec.Name}, - }, r.opts...) - if err != nil { - return nil, err - } - return *instances, nil -} - -func (r *composeRunner) listComposeIngresses(ctx context.Context) ([]hypeman.Ingress, error) { - ingresses, err := r.client.Ingresses.List(ctx, hypeman.IngressListParams{ - Tags: map[string]string{composeTagName: r.spec.Name}, - }, r.opts...) - if err != nil { - return nil, err - } - return *ingresses, nil -} - -func replacementBlockers(actions []composeAction, replace bool) []string { - if replace { - return nil - } - var blockers []string - for _, action := range actions { - if action.Action == "replace" { - blockers = append(blockers, fmt.Sprintf(" %s %s changed: %s", action.Type, action.Name, action.Reason)) - } - } - return blockers -} - -func conflictBlockers(actions []composeAction) []string { - var blockers []string - for _, action := range actions { - if action.Action == "conflict" { - blockers = append(blockers, fmt.Sprintf(" %s %s: %s", action.Type, action.Name, action.Reason)) - } - } - return blockers -} - -func summarizeComposeActions(actions []composeAction) composeSummary { - var summary composeSummary - for _, action := range actions { - switch action.Action { - case "create": - summary.Create++ - case "replace": - summary.Replace++ - case "delete": - summary.Delete++ - case "unchanged": - summary.Unchanged++ - case "skip": - summary.Skip++ - case "conflict": - summary.Conflict++ - } - } - return summary -} - -func showComposeResult(cmd *cli.Command, title string, result composePlan) error { +func showComposeResult(cmd *cli.Command, title string, result compose.Plan) error { format := cmd.Root().String("format") transform := cmd.Root().String("transform") if format == "auto" { @@ -959,7 +141,7 @@ func showComposeResult(cmd *cli.Command, title string, result composePlan) error return ShowJSON(os.Stdout, title, gjson.ParseBytes(data), format, transform) } -func printComposePlan(result composePlan) { +func printComposePlan(result compose.Plan) { fmt.Fprintf(os.Stdout, "Compose file: %s\n", result.File) fmt.Fprintf(os.Stdout, "Name: %s\n\n", result.Name) if len(result.Actions) == 0 { @@ -978,82 +160,34 @@ func printComposePlan(result composePlan) { printComposeSummary(result.Summary) } -func printComposeDone(result composePlan) { +func printComposeDone(result compose.Plan) { fmt.Fprintln(os.Stderr) printComposeSummaryTo(os.Stderr, "Done", result.Summary) } -func printComposeSummary(summary composeSummary) { +func printComposeSummary(summary compose.Summary) { printComposeSummaryTo(os.Stdout, "Summary", summary) } -func printComposeSummaryTo(out *os.File, label string, summary composeSummary) { +func printComposeSummaryTo(out *os.File, label string, summary compose.Summary) { parts := []string{} - if summary.Create > 0 { - parts = append(parts, fmt.Sprintf("%d to create", summary.Create)) - } - if summary.Replace > 0 { - parts = append(parts, fmt.Sprintf("%d to replace", summary.Replace)) - } - if summary.Delete > 0 { - parts = append(parts, fmt.Sprintf("%d to delete", summary.Delete)) - } - if summary.Unchanged > 0 { - parts = append(parts, fmt.Sprintf("%d unchanged", summary.Unchanged)) - } - if summary.Skip > 0 { - parts = append(parts, fmt.Sprintf("%d skipped", summary.Skip)) - } - if summary.Conflict > 0 { - parts = append(parts, fmt.Sprintf("%d conflicts", summary.Conflict)) + for _, part := range []struct { + count int + label string + }{ + {summary.Create, "to create"}, + {summary.Replace, "to replace"}, + {summary.Delete, "to delete"}, + {summary.Unchanged, "unchanged"}, + {summary.Skip, "skipped"}, + {summary.Conflict, "conflicts"}, + } { + if part.count > 0 { + parts = append(parts, fmt.Sprintf("%d %s", part.count, part.label)) + } } if len(parts) == 0 { parts = append(parts, "no changes") } fmt.Fprintf(out, "%s: %s\n", label, strings.Join(parts, ", ")) } - -func composeInstanceName(composeName, serviceName string) string { - return composeName + "-" + serviceName -} - -func composeIngressName(composeName, serviceName string, index int) string { - return fmt.Sprintf("%s-%s-%d", composeName, serviceName, index) -} - -func composeTags(composeName, serviceName, resource, hash string) map[string]string { - return map[string]string{ - composeTagName: composeName, - composeTagService: serviceName, - composeTagResource: resource, - composeTagHash: hash, - } -} - -func shortHash(v any) (string, error) { - data, err := json.Marshal(v) - if err != nil { - return "", err - } - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:])[:12], nil -} - -func isHTTPNotFound(err error) bool { - var apiErr *hypeman.Error - return isNotFoundError(err, &apiErr) -} - -func sortComposeActions(actions []composeAction) { - order := map[string]int{ - "image": 0, - "ingress": 1, - "instance": 2, - } - sort.SliceStable(actions, func(i, j int) bool { - if order[actions[i].Type] != order[actions[j].Type] { - return order[actions[i].Type] < order[actions[j].Type] - } - return actions[i].Name < actions[j].Name - }) -}