diff --git a/README.md b/README.md index 1f6b5da..4a925d3 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 or Dockerfiles, instances, restart/health settings, and ingresses. See [lib/compose/README.md](lib/compose/README.md#compose). +`hypeman compose` applies a small declarative workload file for images or Dockerfiles, retained volumes, instances, restart/health settings, and ingresses. See [lib/compose/README.md](lib/compose/README.md#compose). More ingress features: - Automatic certs diff --git a/lib/compose/README.md b/lib/compose/README.md index 839dee9..8192493 100644 --- a/lib/compose/README.md +++ b/lib/compose/README.md @@ -86,20 +86,28 @@ hypeman compose down -f hypeman.compose.yaml 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. +Retained volumes are never deleted by `up` or `down`. Passing `--volumes` to `down` also deletes the volumes owned by the file and **destroys their data**: + +```sh +hypeman compose down -f hypeman.compose.yaml --volumes +``` + 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. +`plan` renders the desired resources from the compose file, checks whether referenced images exist, then compares the desired volumes, instances, and ingresses against existing resources. Owned instances and ingresses that are no longer declared in the file are planned for deletion (pruning); resources without compose ownership tags are never touched. `up` applies the plan in order: 1. build Dockerfile services whose generated images are missing 2. ensure referenced images exist and are ready -3. create or replace instances -4. create or replace ingresses +3. create declared volumes +4. delete owned instances and ingresses that are no longer declared (pruning frees unique keys such as ingress hostnames before they are reused) +5. create or replace instances +6. 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. +`down` deletes only instances and ingresses tagged as owned by the compose file. Volumes owned by the file are retained and reported as skipped unless `--volumes` is passed. 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: @@ -110,8 +118,49 @@ hypeman.compose.resource hypeman.compose.hash ``` +Volumes get the same ownership tags except `hypeman.compose.service`, because a volume can be shared by multiple services. + 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. +### Retained Volumes + +Top-level `volumes` declare named volumes backed by the Hypeman volume API. Services attach them with `volumes` mount declarations: + +```yaml +version: 1 +name: stateful + +volumes: + data: + size_gb: 10 + logs: + name: stateful-logs-explicit # optional explicit name + size_gb: 1 + +services: + db: + image: postgres:16 + volumes: + - data:/var/lib/postgresql/data # shorthand: volume:/abs/path[:ro|rw] + - volume: logs # mapping form + mount_path: /var/log/db + readonly: true +``` + +By default, volumes are named `-`; set `name` for a stable external name, just like services and ingresses. + +Volumes are created before the instances that mount them and are **retained**: instance replacement (via `--replace`), `compose down`, and pruning never delete them, and `compose up` on an existing volume reports it as unchanged. Deleting a retained volume requires the explicit destructive option `hypeman compose down --volumes`. + +Volumes are immutable once created. Changing a declared volume (for example `size_gb`) makes `plan` report a conflict and blocks `up`, rather than silently recreating the volume and losing data. To resize, restore the original spec or delete the volume explicitly with `compose down --volumes`. + +If instance replacement fails after the old instance was deleted, the retained volume and its data are untouched; re-running `compose up --replace` recreates the instance on the same volume. + +Mount declarations are validated strictly: the referenced volume must be declared, the mount path must be absolute, and duplicate mount paths or mounting the same volume twice in one service are rejected. + +### Strict Parsing + +Compose files are parsed strictly: unknown fields and duplicate keys fail validation before any resource is applied. This applies at every level, including volume mount mappings. + ### Interpolation String values can embed local files or environment variables: diff --git a/lib/compose/compose.go b/lib/compose/compose.go index 621c6d5..b4c983d 100644 --- a/lib/compose/compose.go +++ b/lib/compose/compose.go @@ -14,6 +14,7 @@ const ( composeResourceInstance = "instance" composeResourceIngress = "ingress" composeResourceBuild = "build" + composeResourceVolume = "volume" ) type Runner struct { @@ -21,6 +22,10 @@ type Runner struct { spec composeSpec client hypeman.Client opts []option.RequestOption + + // volumeIDsByName caches the compose volume name→ID lookup for a single + // Up apply pass so each instance create doesn't re-list volumes. + volumeIDsByName map[string]string } type UpOptions struct { @@ -30,6 +35,13 @@ type UpOptions struct { Verbose bool } +type DownOptions struct { + Verbose bool + // Volumes also deletes retained volumes owned by the compose file. + // This destroys their data and cannot be undone. + Volumes bool +} + type Plan struct { Name string `json:"name"` File string `json:"file"` @@ -53,11 +65,17 @@ type Action struct { Service string `json:"service,omitempty"` Reason string `json:"reason"` - instanceID string - ingressID string - instanceInput hypeman.InstanceNewParams - ingressInput hypeman.IngressNewParams - buildInput *desiredBuild + instanceID string + ingressID string + volumeID string + // claimedIngressIDs lists additional owned ingress IDs this action claims + // (e.g. ambiguous rename candidates under a conflict) so prune planning + // does not also propose deleting them. + claimedIngressIDs []string + instanceInput hypeman.InstanceNewParams + ingressInput hypeman.IngressNewParams + volumeInput hypeman.VolumeNewParams + buildInput *desiredBuild } func NewRunner(file string, client hypeman.Client, opts ...option.RequestOption) (*Runner, error) { diff --git a/lib/compose/compose_test.go b/lib/compose/compose_test.go index fb5ad64..a26d46f 100644 --- a/lib/compose/compose_test.go +++ b/lib/compose/compose_test.go @@ -88,7 +88,7 @@ func TestBuildComposeInstanceInputIncludesPolicyFields(t *testing.T) { }, } - input := buildComposeInstanceInput("hypeship-otel-otelcol", service) + input := buildComposeInstanceInput("hypeship-otel-otelcol", service, nil) inputJSON := map[string]any{} inputData, err := json.Marshal(input) require.NoError(t, err) @@ -138,7 +138,7 @@ func TestDesiredResourcesUseDeterministicNamesAndTags(t *testing.T) { }, } - _, instances, ingresses, images, err := runner.desiredResources() + _, _, instances, ingresses, images, err := runner.desiredResources() require.NoError(t, err) require.Equal(t, []string{"otel/opentelemetry-collector-contrib:0.108.0"}, images) @@ -177,7 +177,7 @@ func TestDesiredResourcesUseExplicitResourceNames(t *testing.T) { }, } - _, instances, ingresses, _, err := runner.desiredResources() + _, _, instances, ingresses, _, err := runner.desiredResources() require.NoError(t, err) require.Len(t, instances, 1) @@ -270,7 +270,7 @@ services: require.NoError(t, err) runner := Runner{file: composePath, spec: spec} - builds, instances, _, images, err := runner.desiredResources() + builds, _, instances, _, images, err := runner.desiredResources() require.NoError(t, err) require.Empty(t, images) @@ -280,18 +280,18 @@ services: assert.Regexp(t, `^compose/worker-stack/worker:[a-f0-9]{12}$`, builds[0].Image) assert.Equal(t, builds[0].Image, instances[0].Input.Image) - again, _, _, _, err := runner.desiredResources() + again, _, _, _, _, err := runner.desiredResources() require.NoError(t, err) require.Equal(t, builds[0].Image, again[0].Image) require.NoError(t, os.WriteFile(filepath.Join(dir, ".dockerignore"), []byte("*.tmp\n"), 0644)) - dockerignoreChanged, _, _, _, err := runner.desiredResources() + dockerignoreChanged, _, _, _, _, err := runner.desiredResources() require.NoError(t, err) require.NotEqual(t, builds[0].Image, dockerignoreChanged[0].Image) require.NoError(t, os.Remove(filepath.Join(dir, ".dockerignore"))) require.NoError(t, os.WriteFile(filepath.Join(dir, "worker"), []byte("echo changed\n"), 0644)) - changed, _, _, _, err := runner.desiredResources() + changed, _, _, _, _, err := runner.desiredResources() require.NoError(t, err) require.NotEqual(t, builds[0].Image, changed[0].Image) } @@ -475,4 +475,22 @@ func TestPlanIngressActionConflictsWhenRenameCandidateIsAmbiguous(t *testing.T) assert.Equal(t, "conflict", action.Action) assert.Equal(t, "multiple owned ingresses for service have changed names", action.Reason) assert.Empty(t, action.ingressID) + // The conflict still claims both candidates so prune planning does not + // also propose deleting them. + assert.ElementsMatch(t, []string{"old-http-id", "old-grpc-id"}, action.claimedIngressIDs) +} + +func TestPruneActionsSkipsIngressesClaimedByConflict(t *testing.T) { + owned := []hypeman.Ingress{ + {ID: "old-http-id", Name: "app-api-http", Tags: composeTags("app", "api", composeResourceIngress, "http-hash")}, + {ID: "old-grpc-id", Name: "app-api-grpc", Tags: composeTags("app", "api", composeResourceIngress, "grpc-hash")}, + } + actions := []Action{{ + Action: "conflict", + Type: "ingress", + Name: "app-api-public", + claimedIngressIDs: []string{"old-http-id", "old-grpc-id"}, + }} + + assert.Empty(t, pruneActions(nil, owned, actions)) } diff --git a/lib/compose/desired.go b/lib/compose/desired.go index 982ef00..5cefa02 100644 --- a/lib/compose/desired.go +++ b/lib/compose/desired.go @@ -24,7 +24,13 @@ type desiredIngress struct { Input hypeman.IngressNewParams } -func (r *Runner) desiredResources() ([]desiredBuild, []desiredInstance, []desiredIngress, []string, error) { +type desiredVolume struct { + Name string + Hash string + Input hypeman.VolumeNewParams +} + +func (r *Runner) desiredResources() ([]desiredBuild, []desiredVolume, []desiredInstance, []desiredIngress, []string, error) { serviceNames := make([]string, 0, len(r.spec.Services)) imageSet := map[string]struct{}{} for name, service := range r.spec.Services { @@ -41,6 +47,33 @@ func (r *Runner) desiredResources() ([]desiredBuild, []desiredInstance, []desire } sort.Strings(images) + volumeKeys := make([]string, 0, len(r.spec.Volumes)) + for key := range r.spec.Volumes { + volumeKeys = append(volumeKeys, key) + } + sort.Strings(volumeKeys) + volumeNames := make(map[string]string, len(volumeKeys)) + volumes := make([]desiredVolume, 0, len(volumeKeys)) + for _, key := range volumeKeys { + volumeSpec := r.spec.Volumes[key] + volumeName := composeVolumeName(r.spec.Name, key, volumeSpec) + volumeNames[key] = volumeName + volumeInput := hypeman.VolumeNewParams{ + Name: volumeName, + SizeGB: volumeSpec.SizeGB, + } + volumeHash, err := shortHash(volumeInput) + if err != nil { + return nil, nil, nil, nil, nil, err + } + volumeInput.Tags = composeVolumeTags(r.spec.Name, volumeHash) + volumes = append(volumes, desiredVolume{ + Name: volumeName, + Hash: volumeHash, + Input: volumeInput, + }) + } + var builds []desiredBuild instances := make([]desiredInstance, 0, len(serviceNames)) var ingresses []desiredIngress @@ -49,16 +82,16 @@ func (r *Runner) desiredResources() ([]desiredBuild, []desiredInstance, []desire if service.Dockerfile != "" { build, err := r.desiredBuildForService(serviceName, service) if err != nil { - return nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, err } builds = append(builds, build) service.Image = build.Image } instanceName := composeInstanceName(r.spec.Name, serviceName, service) - instanceInput := buildComposeInstanceInput(instanceName, service) + instanceInput := buildComposeInstanceInput(instanceName, service, volumeNames) instanceHash, err := shortHash(instanceInput) if err != nil { - return nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, err } instanceInput.Tags = composeTags(r.spec.Name, serviceName, composeResourceInstance, instanceHash) instances = append(instances, desiredInstance{ @@ -73,7 +106,7 @@ func (r *Runner) desiredResources() ([]desiredBuild, []desiredInstance, []desire ingressInput := buildComposeIngressInput(instanceName, ingressName, ingressSpec) ingressHash, err := shortHash(ingressInput) if err != nil { - return nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, err } ingressInput.Tags = composeTags(r.spec.Name, serviceName, composeResourceIngress, ingressHash) ingresses = append(ingresses, desiredIngress{ @@ -84,14 +117,33 @@ func (r *Runner) desiredResources() ([]desiredBuild, []desiredInstance, []desire }) } } - return builds, instances, ingresses, images, nil + return builds, volumes, instances, ingresses, images, nil } -func buildComposeInstanceInput(instanceName string, service composeServiceSpec) hypeman.InstanceNewParams { +// buildComposeInstanceInput renders the instance create params for a service. +// volumeNames maps compose volume keys to their resolved Hypeman volume names. +// Mounts carry the volume *name* (not the server-assigned ID) so the rendered +// hash stays stable across volume creation; names are resolved to IDs at apply +// time, immediately before instance creation. +func buildComposeInstanceInput(instanceName string, service composeServiceSpec, volumeNames map[string]string) hypeman.InstanceNewParams { input := hypeman.InstanceNewParams{ Name: instanceName, Image: service.Image, } + if len(service.Volumes) > 0 { + mounts := make([]hypeman.VolumeMountParam, 0, len(service.Volumes)) + for _, mount := range service.Volumes { + volumeMount := hypeman.VolumeMountParam{ + VolumeID: volumeNames[mount.Volume], + MountPath: mount.MountPath, + } + if mount.Readonly { + volumeMount.Readonly = hypeman.Bool(true) + } + mounts = append(mounts, volumeMount) + } + input.Volumes = mounts + } if len(service.Entrypoint) > 0 { input.Entrypoint = service.Entrypoint } @@ -233,6 +285,13 @@ func composeIngressName(composeName, serviceName string, index int, ingress comp return fmt.Sprintf("%s-%s-%d", composeName, serviceName, index) } +func composeVolumeName(composeName, key string, volume composeVolumeSpec) string { + if volume.Name != "" { + return volume.Name + } + return composeName + "-" + key +} + func composeTags(composeName, serviceName, resource, hash string) map[string]string { return map[string]string{ composeTagName: composeName, @@ -242,6 +301,16 @@ func composeTags(composeName, serviceName, resource, hash string) map[string]str } } +// composeVolumeTags tags a retained volume with compose ownership. Volumes are +// shared across services, so they carry no service tag. +func composeVolumeTags(composeName, hash string) map[string]string { + return map[string]string{ + composeTagName: composeName, + composeTagResource: composeResourceVolume, + composeTagHash: hash, + } +} + func shortHash(v any) (string, error) { data, err := json.Marshal(v) if err != nil { diff --git a/lib/compose/reconcile.go b/lib/compose/reconcile.go index 570b214..023f7fb 100644 --- a/lib/compose/reconcile.go +++ b/lib/compose/reconcile.go @@ -13,7 +13,7 @@ import ( ) func (r *Runner) Plan(ctx context.Context) (Plan, error) { - desiredBuilds, desiredInstances, desiredIngresses, images, err := r.desiredResources() + desiredBuilds, desiredVolumes, desiredInstances, desiredIngresses, images, err := r.desiredResources() if err != nil { return Plan{}, err } @@ -39,6 +39,34 @@ func (r *Runner) Plan(ctx context.Context) (Plan, error) { actions = append(actions, action) } + existingVolumes, err := r.listComposeVolumes(ctx) + if err != nil { + return Plan{}, err + } + allVolumes, err := r.client.Volumes.List(ctx, hypeman.VolumeListParams{}, r.opts...) + if err != nil { + return Plan{}, err + } + desiredVolumeNames := map[string]struct{}{} + for _, volume := range desiredVolumes { + desiredVolumeNames[volume.Name] = struct{}{} + actions = append(actions, planVolumeAction(volume, existingVolumes, *allVolumes)) + } + // Retained volumes are never pruned by up/plan, even when they are no + // longer declared. Deleting their data requires `compose down --volumes`. + for _, vol := range existingVolumes { + if _, desired := desiredVolumeNames[vol.Name]; desired { + continue + } + actions = append(actions, Action{ + Action: "skip", + Type: "volume", + Name: vol.Name, + Reason: "retained: not declared in compose file (use `compose down --volumes` to delete)", + volumeID: vol.ID, + }) + } + existingInstances, err := r.listComposeInstances(ctx) if err != nil { return Plan{}, err @@ -47,8 +75,9 @@ func (r *Runner) Plan(ctx context.Context) (Plan, error) { if err != nil { return Plan{}, err } + var instanceActions []Action for _, inst := range desiredInstances { - actions = append(actions, planInstanceAction(inst, existingInstances, *allInstances)) + instanceActions = append(instanceActions, planInstanceAction(inst, existingInstances, *allInstances)) } existingIngresses, err := r.listComposeIngresses(ctx) @@ -60,9 +89,23 @@ func (r *Runner) Plan(ctx context.Context) (Plan, error) { return Plan{}, err } desiredIngressNames := desiredIngressNamesByService(desiredIngresses) + var ingressActions []Action for _, ingress := range desiredIngresses { - actions = append(actions, planIngressAction(ingress, existingIngresses, *allIngresses, desiredIngressNames[ingress.Service])) - } + ingressActions = append(ingressActions, planIngressAction(ingress, existingIngresses, *allIngresses, desiredIngressNames[ingress.Service])) + } + + // Prune owned instances and ingresses that no desired resource claims. + // Unmanaged resources (no compose ownership tags) are never touched. + // Prune deletes are planned before instance/ingress creates and replaces + // (and Up applies actions in plan order) so a pruned resource frees its + // unique keys — names, ingress hostnames — before a new resource reuses + // them. This mirrors applyReplace's delete-then-create. + claimed := make([]Action, 0, len(instanceActions)+len(ingressActions)) + claimed = append(claimed, instanceActions...) + claimed = append(claimed, ingressActions...) + actions = append(actions, pruneActions(existingInstances, existingIngresses, claimed)...) + actions = append(actions, instanceActions...) + actions = append(actions, ingressActions...) return Plan{ Name: r.spec.Name, @@ -84,6 +127,10 @@ func (r *Runner) Up(ctx context.Context, opts UpOptions) (Plan, error) { return result, fmt.Errorf("replace required:\n%s\n\nRun again with --replace to recreate changed resources.", strings.Join(blockers, "\n")) } + // Fresh volume-name lookup for this apply pass; the first instance create + // populates it (after volume creates have run) and later creates reuse it. + r.volumeIDsByName = nil + for i := range result.Actions { action := &result.Actions[i] switch action.Action { @@ -106,6 +153,17 @@ func (r *Runner) Up(ctx context.Context, opts UpOptions) (Plan, error) { if err := r.applyReplace(ctx, action, opts); err != nil { return result, err } + case "delete": + if opts.Verbose { + fmt.Fprintf(os.Stderr, "[delete] %s %s\n", action.Type, action.Name) + } + if err := r.applyDelete(ctx, action); err != nil { + return result, err + } + case "skip": + if opts.Verbose { + fmt.Fprintf(os.Stderr, "[skip] %s %s: %s\n", action.Type, action.Name, action.Reason) + } case "unchanged": if opts.Verbose { fmt.Fprintf(os.Stderr, "[skip] %s %s unchanged\n", action.Type, action.Name) @@ -132,7 +190,11 @@ func (r *Runner) Up(ctx context.Context, opts UpOptions) (Plan, error) { return result, nil } -func (r *Runner) Down(ctx context.Context, verbose bool) (Plan, error) { +// Down deletes instances and ingresses owned by the compose file. Retained +// volumes are kept by default and reported as skipped; they are only deleted +// when DownOptions.Volumes is set (`compose down --volumes`), which destroys +// their data. +func (r *Runner) Down(ctx context.Context, opts DownOptions) (Plan, error) { instances, err := r.listComposeInstances(ctx) if err != nil { return Plan{}, err @@ -141,6 +203,10 @@ func (r *Runner) Down(ctx context.Context, verbose bool) (Plan, error) { if err != nil { return Plan{}, err } + volumes, err := r.listComposeVolumes(ctx) + if err != nil { + return Plan{}, err + } var actions []Action for _, ing := range ingresses { @@ -163,6 +229,25 @@ func (r *Runner) Down(ctx context.Context, verbose bool) (Plan, error) { instanceID: inst.ID, }) } + for _, vol := range volumes { + if opts.Volumes { + actions = append(actions, Action{ + Action: "delete", + Type: "volume", + Name: vol.Name, + Reason: "owned by compose file; --volumes destroys retained data", + volumeID: vol.ID, + }) + continue + } + actions = append(actions, Action{ + Action: "skip", + Type: "volume", + Name: vol.Name, + Reason: "retained (use --volumes to delete)", + volumeID: vol.ID, + }) + } sortComposeActions(actions) result := Plan{ @@ -171,7 +256,7 @@ func (r *Runner) Down(ctx context.Context, verbose bool) (Plan, error) { Actions: actions, Summary: summarizeComposeActions(actions), } - if len(actions) == 0 { + if len(instances) == 0 && len(ingresses) == 0 && len(volumes) == 0 { for serviceName := range r.spec.Services { service := r.spec.Services[serviceName] for i := range service.Ingress { @@ -191,6 +276,19 @@ func (r *Runner) Down(ctx context.Context, verbose bool) (Plan, error) { Reason: "not found", }) } + volumeKeys := make([]string, 0, len(r.spec.Volumes)) + for key := range r.spec.Volumes { + volumeKeys = append(volumeKeys, key) + } + sort.Strings(volumeKeys) + for _, key := range volumeKeys { + result.Actions = append(result.Actions, Action{ + Action: "skip", + Type: "volume", + Name: composeVolumeName(r.spec.Name, key, r.spec.Volumes[key]), + Reason: "not found", + }) + } sortComposeActions(result.Actions) result.Summary = summarizeComposeActions(result.Actions) return result, nil @@ -198,24 +296,41 @@ func (r *Runner) Down(ctx context.Context, verbose bool) (Plan, error) { for i := range actions { action := &actions[i] - if verbose { + if action.Action != "delete" { + if opts.Verbose { + fmt.Fprintf(os.Stderr, "[skip] %s %s: %s\n", action.Type, action.Name, action.Reason) + } + continue + } + if opts.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 - } + if err := r.applyDelete(ctx, action); err != nil { + return result, err } } return result, nil } +func (r *Runner) applyDelete(ctx context.Context, action *Action) error { + switch action.Type { + case "ingress": + if err := r.client.Ingresses.Delete(ctx, action.ingressID, r.opts...); err != nil && !isHTTPNotFound(err) { + return err + } + case "instance": + if err := r.client.Instances.Delete(ctx, action.instanceID, r.opts...); err != nil && !isHTTPNotFound(err) { + return err + } + case "volume": + if err := r.client.Volumes.Delete(ctx, action.volumeID, r.opts...); err != nil && !isHTTPNotFound(err) { + return err + } + } + return nil +} + func (r *Runner) applyCreate(ctx context.Context, action *Action, opts UpOptions) error { switch action.Type { case "build": @@ -235,7 +350,16 @@ func (r *Runner) applyCreate(ctx context.Context, action *Action, opts UpOptions return nil case "image": return r.ensureImageReady(ctx, action.Name, opts.Verbose) + case "volume": + vol, err := r.client.Volumes.New(ctx, action.volumeInput, r.opts...) + if err != nil { + return err + } + action.volumeID = vol.ID case "instance": + if err := r.resolveInstanceVolumeIDs(ctx, &action.instanceInput); err != nil { + return err + } inst, err := r.client.Instances.New(ctx, action.instanceInput, r.opts...) if err != nil { return err @@ -410,6 +534,96 @@ func planInstanceAction(desired desiredInstance, owned []hypeman.Instance, all [ return action } +// planVolumeAction reconciles one declared retained volume. Volumes are +// immutable once created: a spec change is a conflict that blocks `up` rather +// than a replacement, because replacing a volume would destroy its data. +func planVolumeAction(desired desiredVolume, owned []hypeman.Volume, all []hypeman.Volume) Action { + action := Action{ + Type: "volume", + Name: desired.Name, + volumeInput: desired.Input, + } + for _, vol := range owned { + if vol.Name != desired.Name { + continue + } + action.volumeID = vol.ID + if vol.Tags[composeTagHash] == desired.Hash { + action.Action = "unchanged" + action.Reason = "hash matches" + return action + } + action.Action = "conflict" + action.Reason = "retained volume spec changed; volumes are immutable (restore the declared spec or delete the volume with `compose down --volumes`)" + return action + } + for _, vol := range all { + if vol.Name == desired.Name { + action.Action = "conflict" + if project := vol.Tags[composeTagName]; project != "" { + action.Reason = fmt.Sprintf("name is owned by a different compose project %q", project) + } else { + action.Reason = "name exists without compose ownership" + } + action.volumeID = vol.ID + return action + } + } + action.Action = "create" + action.Reason = "missing" + return action +} + +// pruneActions returns delete actions for owned instances and ingresses that +// no desired action claims (removed from the compose file or superseded). +// Resources without compose ownership tags are never included. +func pruneActions(ownedInstances []hypeman.Instance, ownedIngresses []hypeman.Ingress, actions []Action) []Action { + claimedInstances := map[string]struct{}{} + claimedIngresses := map[string]struct{}{} + for _, action := range actions { + if action.instanceID != "" { + claimedInstances[action.instanceID] = struct{}{} + } + if action.ingressID != "" { + claimedIngresses[action.ingressID] = struct{}{} + } + // Ambiguous-rename conflicts carry no single ingressID but still + // claim their candidates so plan doesn't also propose deleting them. + for _, id := range action.claimedIngressIDs { + claimedIngresses[id] = struct{}{} + } + } + var pruned []Action + for _, inst := range ownedInstances { + if _, claimed := claimedInstances[inst.ID]; claimed { + continue + } + pruned = append(pruned, Action{ + Action: "delete", + Type: "instance", + Name: inst.Name, + Service: inst.Tags[composeTagService], + Reason: "no longer declared in compose file", + instanceID: inst.ID, + }) + } + for _, ing := range ownedIngresses { + if _, claimed := claimedIngresses[ing.ID]; claimed { + continue + } + pruned = append(pruned, Action{ + Action: "delete", + Type: "ingress", + Name: ing.Name, + Service: ing.Tags[composeTagService], + Reason: "no longer declared in compose file", + ingressID: ing.ID, + }) + } + sortComposeActions(pruned) + return pruned +} + func planIngressAction(desired desiredIngress, owned []hypeman.Ingress, all []hypeman.Ingress, desiredServiceIngressNames map[string]struct{}) Action { action := Action{ Type: "ingress", @@ -455,6 +669,11 @@ func planIngressAction(desired desiredIngress, owned []hypeman.Ingress, all []hy if len(renameCandidates) > 1 { action.Action = "conflict" action.Reason = "multiple owned ingresses for service have changed names" + // The conflict blocks Up, but claim the candidates so pruneActions + // doesn't also plan deletes for them. + for _, ing := range renameCandidates { + action.claimedIngressIDs = append(action.claimedIngressIDs, ing.ID) + } return action } for _, ing := range all { @@ -501,6 +720,48 @@ func (r *Runner) listComposeIngresses(ctx context.Context) ([]hypeman.Ingress, e return *ingresses, nil } +func (r *Runner) listComposeVolumes(ctx context.Context) ([]hypeman.Volume, error) { + volumes, err := r.client.Volumes.List(ctx, hypeman.VolumeListParams{ + Tags: map[string]string{composeTagName: r.spec.Name}, + }, r.opts...) + if err != nil { + return nil, err + } + return *volumes, nil +} + +// resolveInstanceVolumeIDs swaps the volume names carried in planned mounts +// for the server-assigned volume IDs required by instance creation. Planned +// mounts deliberately carry names so the rendered hash is stable before the +// volume exists; resolution happens here, immediately before creation. +func (r *Runner) resolveInstanceVolumeIDs(ctx context.Context, input *hypeman.InstanceNewParams) error { + if len(input.Volumes) == 0 { + return nil + } + // The name→ID lookup is listed once per apply pass and shared across + // instance creates. Volume actions are planned before instance actions, + // so the first resolve already sees every volume created this pass. + if r.volumeIDsByName == nil { + volumes, err := r.listComposeVolumes(ctx) + if err != nil { + return err + } + r.volumeIDsByName = make(map[string]string, len(volumes)) + for _, vol := range volumes { + r.volumeIDsByName[vol.Name] = vol.ID + } + } + for i := range input.Volumes { + name := input.Volumes[i].VolumeID + id, ok := r.volumeIDsByName[name] + if !ok { + return fmt.Errorf("volume %s for instance %s not found (volume creation may have failed)", name, input.Name) + } + input.Volumes[i].VolumeID = id + } + return nil +} + func replacementBlockers(actions []Action, replace bool) []string { if replace { return nil @@ -574,6 +835,7 @@ func sortComposeActions(actions []Action) { "image": 0, "ingress": 1, "instance": 2, + "volume": 3, } sort.SliceStable(actions, func(i, j int) bool { if order[actions[i].Type] != order[actions[j].Type] { diff --git a/lib/compose/spec.go b/lib/compose/spec.go index eff08af..9337433 100644 --- a/lib/compose/spec.go +++ b/lib/compose/spec.go @@ -1,6 +1,7 @@ package compose import ( + "bytes" "fmt" "os" "path/filepath" @@ -15,6 +16,92 @@ type composeSpec struct { Version int `json:"version" yaml:"version"` Name string `json:"name" yaml:"name"` Services map[string]composeServiceSpec `json:"services" yaml:"services"` + Volumes map[string]composeVolumeSpec `json:"volumes,omitempty" yaml:"volumes,omitempty"` +} + +// composeVolumeSpec declares a retained named volume. Volumes are created +// before instances, survive instance replacement and `compose down`, and are +// only destroyed by an explicit destructive option (`compose down --volumes`). +type composeVolumeSpec struct { + Name string `json:"name,omitempty" yaml:"name"` + SizeGB int64 `json:"size_gb" yaml:"size_gb"` +} + +// composeVolumeMountSpec attaches a declared volume to a service. It accepts +// either the shorthand string form "volume:/abs/path[:ro|rw]" or the mapping +// form: +// +// volume: data +// mount_path: /var/lib/data +// readonly: true +type composeVolumeMountSpec struct { + Volume string `json:"volume" yaml:"volume"` + MountPath string `json:"mount_path" yaml:"mount_path"` + Readonly bool `json:"readonly,omitempty" yaml:"readonly"` +} + +func (m *composeVolumeMountSpec) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + var shorthand string + if err := node.Decode(&shorthand); err != nil { + return err + } + parsed, err := parseComposeVolumeMountShorthand(shorthand) + if err != nil { + return fmt.Errorf("line %d: %w", node.Line, err) + } + *m = parsed + return nil + case yaml.MappingNode: + // The top-level strict decoder hands custom unmarshalers the raw + // node, so enforce known fields here explicitly. + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + switch key.Value { + case "volume", "mount_path", "readonly": + default: + return fmt.Errorf("line %d: field %s not found in volume mount", key.Line, key.Value) + } + } + type rawMount composeVolumeMountSpec + var raw rawMount + if err := node.Decode(&raw); err != nil { + return err + } + *m = composeVolumeMountSpec(raw) + return nil + default: + return fmt.Errorf("line %d: volume mount must be a string or mapping", node.Line) + } +} + +func parseComposeVolumeMountShorthand(value string) (composeVolumeMountSpec, error) { + parts := strings.Split(value, ":") + if len(parts) < 2 || len(parts) > 3 { + return composeVolumeMountSpec{}, fmt.Errorf("volume mount %q must be in the form volume:/abs/path[:ro|rw]", value) + } + mount := composeVolumeMountSpec{ + Volume: strings.TrimSpace(parts[0]), + MountPath: parts[1], + } + if mount.Volume == "" { + return composeVolumeMountSpec{}, fmt.Errorf("volume mount %q is missing the volume name", value) + } + if mount.MountPath == "" { + return composeVolumeMountSpec{}, fmt.Errorf("volume mount %q is missing the mount path", value) + } + if len(parts) == 3 { + switch parts[2] { + case "ro": + mount.Readonly = true + case "rw": + mount.Readonly = false + default: + return composeVolumeMountSpec{}, fmt.Errorf("volume mount %q has invalid mode %q (must be ro or rw)", value, parts[2]) + } + } + return mount, nil } type composeServiceSpec struct { @@ -28,6 +115,7 @@ type composeServiceSpec struct { Restart *composeRestartSpec `json:"restart,omitempty" yaml:"restart"` Health *composeCheckSpec `json:"healthcheck,omitempty" yaml:"healthcheck"` Ingress []composeIngressRuleSpec `json:"ingress,omitempty" yaml:"ingress"` + Volumes []composeVolumeMountSpec `json:"volumes,omitempty" yaml:"volumes"` } type composeResourcesSpec struct { @@ -90,7 +178,9 @@ func loadComposeSpec(path string) (composeSpec, error) { return composeSpec{}, fmt.Errorf("read compose file: %w", err) } var spec composeSpec - if err := yaml.Unmarshal(data, &spec); err != nil { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&spec); err != nil { return composeSpec{}, fmt.Errorf("parse compose file: %w", err) } if err := interpolateComposeSpec(&spec, filepath.Dir(path)); err != nil { @@ -118,6 +208,27 @@ func validateComposeSpec(spec *composeSpec) error { return fmt.Errorf("compose services must include at least one service") } + volumeNames := map[string]string{} + for key, volume := range spec.Volumes { + if !composeNamePattern.MatchString(key) { + return fmt.Errorf("volume %q must contain only lowercase letters, digits, and dashes", key) + } + if volume.Name != "" && !composeNamePattern.MatchString(volume.Name) { + return fmt.Errorf("volume %q name must contain only lowercase letters, digits, and dashes", key) + } + volumeName := composeVolumeName(spec.Name, key, volume) + if len(volumeName) > 63 { + return fmt.Errorf("volume %q produces volume name %q longer than 63 characters", key, volumeName) + } + if existing, ok := volumeNames[volumeName]; ok { + return fmt.Errorf("volume %q produces duplicate volume name %q already used by volume %q", key, volumeName, existing) + } + volumeNames[volumeName] = key + if volume.SizeGB <= 0 { + return fmt.Errorf("volume %q size_gb must be positive", key) + } + } + instanceNames := map[string]string{} ingressNames := map[string]string{} for name, service := range spec.Services { @@ -141,6 +252,30 @@ func validateComposeSpec(spec *composeSpec) error { if service.Image != "" && service.Dockerfile != "" { return fmt.Errorf("service %q cannot include both image and dockerfile", name) } + mountPaths := map[string]int{} + mountedVolumes := map[string]int{} + for i, mount := range service.Volumes { + if mount.Volume == "" { + return fmt.Errorf("service %q volume mount %d volume is required", name, i) + } + if _, ok := spec.Volumes[mount.Volume]; !ok { + return fmt.Errorf("service %q volume mount %d references unknown volume %q", name, i, mount.Volume) + } + if mount.MountPath == "" { + return fmt.Errorf("service %q volume mount %d mount_path is required", name, i) + } + if !strings.HasPrefix(mount.MountPath, "/") { + return fmt.Errorf("service %q volume mount %d mount_path %q must be an absolute path", name, i, mount.MountPath) + } + if existing, ok := mountPaths[mount.MountPath]; ok { + return fmt.Errorf("service %q volume mount %d duplicates mount_path %q already used by mount %d", name, i, mount.MountPath, existing) + } + mountPaths[mount.MountPath] = i + if existing, ok := mountedVolumes[mount.Volume]; ok { + return fmt.Errorf("service %q volume mount %d mounts volume %q more than once (already used by mount %d)", name, i, mount.Volume, existing) + } + mountedVolumes[mount.Volume] = i + } for i, rule := range service.Ingress { ingressName := composeIngressName(spec.Name, name, i, rule) if rule.Name != "" && !composeNamePattern.MatchString(rule.Name) { diff --git a/lib/compose/volumes_test.go b/lib/compose/volumes_test.go new file mode 100644 index 0000000..96c150a --- /dev/null +++ b/lib/compose/volumes_test.go @@ -0,0 +1,1173 @@ +package compose + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseComposeVolumeMountShorthand(t *testing.T) { + mount, err := parseComposeVolumeMountShorthand("data:/var/lib/data") + require.NoError(t, err) + assert.Equal(t, composeVolumeMountSpec{Volume: "data", MountPath: "/var/lib/data"}, mount) + + mount, err = parseComposeVolumeMountShorthand("data:/var/lib/data:ro") + require.NoError(t, err) + assert.Equal(t, composeVolumeMountSpec{Volume: "data", MountPath: "/var/lib/data", Readonly: true}, mount) + + mount, err = parseComposeVolumeMountShorthand("data:/var/lib/data:rw") + require.NoError(t, err) + assert.Equal(t, composeVolumeMountSpec{Volume: "data", MountPath: "/var/lib/data"}, mount) + + for _, invalid := range []string{ + "data", + "data:", + ":/var/lib/data", + "data:/var/lib/data:ro:extra", + "data:/var/lib/data:readonly", + "data:/var/lib/data:", + } { + _, err := parseComposeVolumeMountShorthand(invalid) + assert.Error(t, err, "expected %q to be rejected", invalid) + } +} + +func TestLoadComposeSpecRejectsUnknownFields(t *testing.T) { + dir := t.TempDir() + composePath := filepath.Join(dir, "hypeman.compose.yaml") + require.NoError(t, os.WriteFile(composePath, []byte(` +version: 1 +name: worker-stack +surprise: true +services: + worker: + image: alpine:latest +`), 0644)) + + _, err := loadComposeSpec(composePath) + require.ErrorContains(t, err, "field surprise not found") + + require.NoError(t, os.WriteFile(composePath, []byte(` +version: 1 +name: worker-stack +services: + worker: + image: alpine:latest + bogus: 1 +`), 0644)) + _, err = loadComposeSpec(composePath) + require.ErrorContains(t, err, "field bogus not found") + + require.NoError(t, os.WriteFile(composePath, []byte(` +version: 1 +name: worker-stack +volumes: + data: + size_gb: 5 +services: + worker: + image: alpine:latest + volumes: + - volume: data + mount_path: /var/lib/data + encrypt: true +`), 0644)) + _, err = loadComposeSpec(composePath) + require.ErrorContains(t, err, "field encrypt not found") +} + +func TestLoadComposeSpecRejectsDuplicateKeys(t *testing.T) { + dir := t.TempDir() + composePath := filepath.Join(dir, "hypeman.compose.yaml") + require.NoError(t, os.WriteFile(composePath, []byte(` +version: 1 +name: worker-stack +name: other-stack +services: + worker: + image: alpine:latest +`), 0644)) + + _, err := loadComposeSpec(composePath) + require.ErrorContains(t, err, "already defined") +} + +func TestLoadComposeSpecAcceptsVolumes(t *testing.T) { + dir := t.TempDir() + composePath := filepath.Join(dir, "hypeman.compose.yaml") + require.NoError(t, os.WriteFile(composePath, []byte(` +version: 1 +name: stateful +volumes: + data: + size_gb: 5 + logs: + name: stateful-logs-explicit + size_gb: 1 +services: + db: + image: postgres:16 + volumes: + - data:/var/lib/postgresql/data + - volume: logs + mount_path: /var/log/db + readonly: true +`), 0644)) + + spec, err := loadComposeSpec(composePath) + require.NoError(t, err) + + require.Len(t, spec.Volumes, 2) + assert.Equal(t, int64(5), spec.Volumes["data"].SizeGB) + assert.Equal(t, "stateful-logs-explicit", spec.Volumes["logs"].Name) + + service := spec.Services["db"] + require.Len(t, service.Volumes, 2) + assert.Equal(t, composeVolumeMountSpec{Volume: "data", MountPath: "/var/lib/postgresql/data"}, service.Volumes[0]) + assert.Equal(t, composeVolumeMountSpec{Volume: "logs", MountPath: "/var/log/db", Readonly: true}, service.Volumes[1]) +} + +func TestValidateComposeSpecVolumes(t *testing.T) { + base := func() *composeSpec { + return &composeSpec{ + Version: 1, + Name: "stateful", + Volumes: map[string]composeVolumeSpec{ + "data": {SizeGB: 5}, + }, + Services: map[string]composeServiceSpec{ + "db": { + Image: "postgres:16", + Volumes: []composeVolumeMountSpec{{Volume: "data", MountPath: "/var/lib/postgresql/data"}}, + }, + }, + } + } + + require.NoError(t, validateComposeSpec(base())) + + cases := []struct { + name string + mutate func(*composeSpec) + wantErr string + }{ + { + name: "invalid volume key", + mutate: func(s *composeSpec) { + s.Volumes = map[string]composeVolumeSpec{"BadName": {SizeGB: 5}} + }, + wantErr: `volume "BadName" must contain only lowercase letters, digits, and dashes`, + }, + { + name: "invalid explicit volume name", + mutate: func(s *composeSpec) { + s.Volumes = map[string]composeVolumeSpec{"data": {Name: "BadName", SizeGB: 5}} + }, + wantErr: `volume "data" name must contain only lowercase letters, digits, and dashes`, + }, + { + name: "duplicate resolved volume names", + mutate: func(s *composeSpec) { + s.Volumes = map[string]composeVolumeSpec{ + "data": {SizeGB: 5}, + "other": {Name: "stateful-data", SizeGB: 5}, + } + }, + wantErr: `produces duplicate volume name "stateful-data"`, + }, + { + name: "missing size", + mutate: func(s *composeSpec) { + s.Volumes = map[string]composeVolumeSpec{"data": {}} + }, + wantErr: `volume "data" size_gb must be positive`, + }, + { + name: "mount references unknown volume", + mutate: func(s *composeSpec) { + service := s.Services["db"] + service.Volumes = []composeVolumeMountSpec{{Volume: "missing", MountPath: "/data"}} + s.Services["db"] = service + }, + wantErr: `service "db" volume mount 0 references unknown volume "missing"`, + }, + { + name: "mount missing volume", + mutate: func(s *composeSpec) { + service := s.Services["db"] + service.Volumes = []composeVolumeMountSpec{{MountPath: "/data"}} + s.Services["db"] = service + }, + wantErr: `service "db" volume mount 0 volume is required`, + }, + { + name: "mount missing path", + mutate: func(s *composeSpec) { + service := s.Services["db"] + service.Volumes = []composeVolumeMountSpec{{Volume: "data"}} + s.Services["db"] = service + }, + wantErr: `service "db" volume mount 0 mount_path is required`, + }, + { + name: "mount path must be absolute", + mutate: func(s *composeSpec) { + service := s.Services["db"] + service.Volumes = []composeVolumeMountSpec{{Volume: "data", MountPath: "relative/path"}} + s.Services["db"] = service + }, + wantErr: `mount_path "relative/path" must be an absolute path`, + }, + { + name: "duplicate mount path", + mutate: func(s *composeSpec) { + s.Volumes["logs"] = composeVolumeSpec{SizeGB: 1} + service := s.Services["db"] + service.Volumes = append(service.Volumes, composeVolumeMountSpec{Volume: "logs", MountPath: "/var/lib/postgresql/data"}) + s.Services["db"] = service + }, + wantErr: `duplicates mount_path "/var/lib/postgresql/data"`, + }, + { + name: "same volume mounted twice", + mutate: func(s *composeSpec) { + service := s.Services["db"] + service.Volumes = append(service.Volumes, composeVolumeMountSpec{Volume: "data", MountPath: "/elsewhere"}) + s.Services["db"] = service + }, + wantErr: `mounts volume "data" more than once`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + spec := base() + tc.mutate(spec) + require.ErrorContains(t, validateComposeSpec(spec), tc.wantErr) + }) + } +} + +func TestDesiredResourcesRendersVolumesAndMounts(t *testing.T) { + runner := Runner{ + spec: composeSpec{ + Version: 1, + Name: "stateful", + Volumes: map[string]composeVolumeSpec{ + "data": {SizeGB: 5}, + "logs": {Name: "stateful-logs-explicit", SizeGB: 1}, + }, + Services: map[string]composeServiceSpec{ + "db": { + Image: "postgres:16", + Volumes: []composeVolumeMountSpec{ + {Volume: "data", MountPath: "/var/lib/postgresql/data"}, + {Volume: "logs", MountPath: "/var/log/db", Readonly: true}, + }, + }, + }, + }, + } + + _, volumes, instances, _, _, err := runner.desiredResources() + require.NoError(t, err) + + require.Len(t, volumes, 2) + assert.Equal(t, "stateful-data", volumes[0].Name) + assert.Equal(t, int64(5), volumes[0].Input.SizeGB) + assert.Equal(t, composeResourceVolume, volumes[0].Input.Tags[composeTagResource]) + assert.NotEmpty(t, volumes[0].Input.Tags[composeTagHash]) + assert.Equal(t, "stateful-logs-explicit", volumes[1].Name) + + require.Len(t, instances, 1) + require.Len(t, instances[0].Input.Volumes, 2) + // Mounts carry the volume name (not the server ID) so the rendered hash is + // stable before the volume exists. + assert.Equal(t, "stateful-data", instances[0].Input.Volumes[0].VolumeID) + assert.Equal(t, "/var/lib/postgresql/data", instances[0].Input.Volumes[0].MountPath) + assert.Equal(t, "stateful-logs-explicit", instances[0].Input.Volumes[1].VolumeID) + assert.True(t, instances[0].Input.Volumes[1].Readonly.Valid()) + + // Rendering is deterministic: identical input yields identical hashes. + _, volumesAgain, instancesAgain, _, _, err := runner.desiredResources() + require.NoError(t, err) + require.Equal(t, volumes[0].Hash, volumesAgain[0].Hash) + require.Equal(t, instances[0].Hash, instancesAgain[0].Hash) +} + +func TestPlanVolumeAction(t *testing.T) { + desired := desiredVolume{ + Name: "stateful-data", + Hash: "hash-a", + Input: hypeman.VolumeNewParams{ + Name: "stateful-data", + SizeGB: 5, + }, + } + + action := planVolumeAction(desired, nil, nil) + assert.Equal(t, "create", action.Action) + assert.Equal(t, "missing", action.Reason) + + owned := []hypeman.Volume{{ + ID: "vol-1", + Name: "stateful-data", + Tags: composeVolumeTags("stateful", "hash-a"), + }} + action = planVolumeAction(desired, owned, nil) + assert.Equal(t, "unchanged", action.Action) + assert.Equal(t, "vol-1", action.volumeID) + + // A spec change is a conflict, never a replace: replacing would destroy data. + owned[0].Tags = composeVolumeTags("stateful", "hash-b") + action = planVolumeAction(desired, owned, nil) + assert.Equal(t, "conflict", action.Action) + assert.Contains(t, action.Reason, "immutable") + assert.Equal(t, "vol-1", action.volumeID) + + // An unmanaged volume with the same name is a conflict. + action = planVolumeAction(desired, nil, []hypeman.Volume{{ID: "vol-9", Name: "stateful-data"}}) + assert.Equal(t, "conflict", action.Action) + assert.Equal(t, "name exists without compose ownership", action.Reason) + + // A volume owned by a different compose project is also a conflict, with + // a reason that says so rather than claiming missing ownership. + action = planVolumeAction(desired, nil, []hypeman.Volume{{ + ID: "vol-10", + Name: "stateful-data", + Tags: composeVolumeTags("other-project", "hash-z"), + }}) + assert.Equal(t, "conflict", action.Action) + assert.Equal(t, `name is owned by a different compose project "other-project"`, action.Reason) +} + +// fakeHypeman is an in-memory Hypeman API seam covering the endpoints compose +// uses. Volumes carry a data payload so tests can prove data (a nonce written +// by the guest) survives instance replacement and down/up. +type fakeHypeman struct { + t *testing.T + + mu sync.Mutex + volumes map[string]*fakeVolume + instances map[string]*fakeInstance + ingresses map[string]*fakeIngress + nextID int + requests []string + + failInstanceCreates int +} + +type fakeIngress struct { + id string + name string + hostnames []string + tags map[string]string +} + +type fakeVolume struct { + id string + name string + sizeGB int64 + tags map[string]string + data string +} + +type fakeInstance struct { + id string + name string + image string + tags map[string]string + volumes []hypeman.VolumeMountParam +} + +func newFakeHypeman(t *testing.T) (*fakeHypeman, *httptest.Server) { + t.Helper() + fake := &fakeHypeman{ + t: t, + volumes: map[string]*fakeVolume{}, + instances: map[string]*fakeInstance{}, + ingresses: map[string]*fakeIngress{}, + } + server := httptest.NewServer(http.HandlerFunc(fake.serve)) + t.Cleanup(server.Close) + return fake, server +} + +func (f *fakeHypeman) id(prefix string) string { + f.nextID++ + return fmt.Sprintf("%s-%d", prefix, f.nextID) +} + +func (f *fakeHypeman) serve(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.requests = append(f.requests, r.Method+" "+r.URL.Path) + f.mu.Unlock() + + path := strings.TrimPrefix(r.URL.Path, "/") + switch { + case path == "volumes" && r.Method == http.MethodGet: + f.listVolumes(w, r) + case path == "volumes" && r.Method == http.MethodPost: + f.createVolume(w, r) + case strings.HasPrefix(path, "volumes/") && r.Method == http.MethodDelete: + f.deleteVolume(w, strings.TrimPrefix(path, "volumes/")) + case path == "instances" && r.Method == http.MethodGet: + f.listInstances(w, r) + case path == "instances" && r.Method == http.MethodPost: + f.createInstance(w, r) + case strings.HasPrefix(path, "instances/") && r.Method == http.MethodDelete: + f.deleteInstance(w, strings.TrimPrefix(path, "instances/")) + case path == "ingresses" && r.Method == http.MethodGet: + f.listIngresses(w, r) + case path == "ingresses" && r.Method == http.MethodPost: + f.createIngress(w, r) + case strings.HasPrefix(path, "ingresses/") && r.Method == http.MethodDelete: + f.deleteIngress(w, strings.TrimPrefix(path, "ingresses/")) + case strings.HasPrefix(path, "images/") && r.Method == http.MethodGet: + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + case path == "images" && r.Method == http.MethodPost: + var body map[string]any + require.NoError(f.t, json.NewDecoder(r.Body).Decode(&body)) + writeJSON(w, map[string]any{ + "created_at": time.Now().UTC().Format(time.RFC3339), + "digest": "sha256:fake", + "name": body["name"], + "status": "ready", + }) + default: + http.Error(w, fmt.Sprintf(`{"error":"unhandled %s %s"}`, r.Method, r.URL.Path), http.StatusNotFound) + } +} + +func tagFilter(r *http.Request) map[string]string { + tags := map[string]string{} + for key, values := range r.URL.Query() { + if !strings.HasPrefix(key, "tags[") || !strings.HasSuffix(key, "]") || len(values) == 0 { + continue + } + tags[strings.TrimSuffix(strings.TrimPrefix(key, "tags["), "]")] = values[0] + } + return tags +} + +func tagsMatch(resource, filter map[string]string) bool { + for key, value := range filter { + if resource[key] != value { + return false + } + } + return true +} + +func (f *fakeHypeman) listVolumes(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + filter := tagFilter(r) + out := []map[string]any{} + for _, vol := range f.volumes { + if !tagsMatch(vol.tags, filter) { + continue + } + out = append(out, map[string]any{ + "id": vol.id, + "created_at": time.Now().UTC().Format(time.RFC3339), + "name": vol.name, + "size_gb": vol.sizeGB, + "tags": vol.tags, + }) + } + writeJSON(w, out) +} + +func (f *fakeHypeman) createVolume(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + SizeGB int64 `json:"size_gb"` + Tags map[string]string `json:"tags"` + } + require.NoError(f.t, json.NewDecoder(r.Body).Decode(&body)) + f.mu.Lock() + defer f.mu.Unlock() + vol := &fakeVolume{id: f.id("vol"), name: body.Name, sizeGB: body.SizeGB, tags: body.Tags} + f.volumes[vol.id] = vol + writeJSON(w, map[string]any{ + "id": vol.id, + "created_at": time.Now().UTC().Format(time.RFC3339), + "name": vol.name, + "size_gb": vol.sizeGB, + "tags": vol.tags, + }) +} + +func (f *fakeHypeman) deleteVolume(w http.ResponseWriter, id string) { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.volumes[id]; !ok { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + delete(f.volumes, id) + w.WriteHeader(http.StatusNoContent) +} + +func (f *fakeHypeman) listInstances(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + filter := tagFilter(r) + out := []map[string]any{} + for _, inst := range f.instances { + if !tagsMatch(inst.tags, filter) { + continue + } + out = append(out, map[string]any{ + "id": inst.id, + "created_at": time.Now().UTC().Format(time.RFC3339), + "image": inst.image, + "name": inst.name, + "state": "Running", + "tags": inst.tags, + }) + } + writeJSON(w, out) +} + +func (f *fakeHypeman) createInstance(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Image string `json:"image"` + Tags map[string]string `json:"tags"` + Volumes []hypeman.VolumeMountParam `json:"volumes"` + } + require.NoError(f.t, json.NewDecoder(r.Body).Decode(&body)) + f.mu.Lock() + defer f.mu.Unlock() + if f.failInstanceCreates > 0 { + f.failInstanceCreates-- + http.Error(w, `{"error":"simulated instance create failure"}`, http.StatusInternalServerError) + return + } + inst := &fakeInstance{id: f.id("inst"), name: body.Name, image: body.Image, tags: body.Tags, volumes: body.Volumes} + f.instances[inst.id] = inst + writeJSON(w, map[string]any{ + "id": inst.id, + "created_at": time.Now().UTC().Format(time.RFC3339), + "image": inst.image, + "name": inst.name, + "state": "Running", + "tags": inst.tags, + }) +} + +func (f *fakeHypeman) deleteInstance(w http.ResponseWriter, id string) { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.instances[id]; !ok { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + delete(f.instances, id) + w.WriteHeader(http.StatusNoContent) +} + +func ingressJSON(ing *fakeIngress) map[string]any { + rules := []map[string]any{} + for _, hostname := range ing.hostnames { + rules = append(rules, map[string]any{ + "match": map[string]any{"hostname": hostname}, + "target": map[string]any{"instance": "", "port": 0}, + }) + } + return map[string]any{ + "id": ing.id, + "created_at": time.Now().UTC().Format(time.RFC3339), + "name": ing.name, + "rules": rules, + "tags": ing.tags, + } +} + +func (f *fakeHypeman) listIngresses(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + filter := tagFilter(r) + out := []map[string]any{} + for _, ing := range f.ingresses { + if !tagsMatch(ing.tags, filter) { + continue + } + out = append(out, ingressJSON(ing)) + } + writeJSON(w, out) +} + +func (f *fakeHypeman) createIngress(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Tags map[string]string `json:"tags"` + Rules []struct { + Match struct { + Hostname string `json:"hostname"` + } `json:"match"` + } `json:"rules"` + } + require.NoError(f.t, json.NewDecoder(r.Body).Decode(&body)) + f.mu.Lock() + defer f.mu.Unlock() + var hostnames []string + for _, rule := range body.Rules { + hostnames = append(hostnames, rule.Match.Hostname) + } + // The server enforces hostname uniqueness across ingresses. + for _, existing := range f.ingresses { + for _, taken := range existing.hostnames { + for _, want := range hostnames { + if want != "" && want == taken { + http.Error(w, fmt.Sprintf(`{"error":"hostname %s is already in use by ingress %s"}`, want, existing.name), http.StatusConflict) + return + } + } + } + } + ing := &fakeIngress{id: f.id("ing"), name: body.Name, hostnames: hostnames, tags: body.Tags} + f.ingresses[ing.id] = ing + writeJSON(w, ingressJSON(ing)) +} + +func (f *fakeHypeman) deleteIngress(w http.ResponseWriter, id string) { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.ingresses[id]; !ok { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + delete(f.ingresses, id) + w.WriteHeader(http.StatusNoContent) +} + +func (f *fakeHypeman) onlyInstance() *fakeInstance { + f.mu.Lock() + defer f.mu.Unlock() + require.Len(f.t, f.instances, 1) + for _, inst := range f.instances { + return inst + } + return nil +} + +func (f *fakeHypeman) volumeCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.volumes) +} + +func (f *fakeHypeman) onlyVolume() *fakeVolume { + f.mu.Lock() + defer f.mu.Unlock() + require.Len(f.t, f.volumes, 1) + for _, vol := range f.volumes { + return vol + } + return nil +} + +func (f *fakeHypeman) countRequests(prefix string) int { + f.mu.Lock() + defer f.mu.Unlock() + count := 0 + for _, req := range f.requests { + if strings.HasPrefix(req, prefix) { + count++ + } + } + return count +} + +func (f *fakeHypeman) requestIndex(prefix string) int { + return f.requestIndexAfter(prefix, 0) +} + +func (f *fakeHypeman) requestIndexAfter(prefix string, after int) int { + f.mu.Lock() + defer f.mu.Unlock() + for i, req := range f.requests { + if i >= after && strings.HasPrefix(req, prefix) { + return i + } + } + return -1 +} + +func (f *fakeHypeman) requestCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.requests) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func writeComposeFile(t *testing.T, dir, contents string) string { + t.Helper() + path := filepath.Join(dir, "hypeman.compose.yaml") + require.NoError(t, os.WriteFile(path, []byte(contents), 0644)) + return path +} + +func newTestRunner(t *testing.T, composePath string, server *httptest.Server) *Runner { + t.Helper() + client := hypeman.NewClient(option.WithBaseURL(server.URL)) + runner, err := NewRunner(composePath, client) + require.NoError(t, err) + return runner +} + +const statefulComposeFile = ` +version: 1 +name: stateful +volumes: + data: + size_gb: 5 +services: + db: + image: postgres:16 + volumes: + - data:/var/lib/postgresql/data +` + +func TestComposeUpDownUpRetainsVolumeAndNonce(t *testing.T) { + fake, server := newFakeHypeman(t) + dir := t.TempDir() + composePath := writeComposeFile(t, dir, statefulComposeFile) + ctx := context.Background() + + runner := newTestRunner(t, composePath, server) + plan, err := runner.Up(ctx, UpOptions{}) + require.NoError(t, err) + assert.Equal(t, 3, plan.Summary.Create) // image + volume + instance + + // Volumes are created before instances. + volumeCreate := fake.requestIndex("POST /volumes") + instanceCreate := fake.requestIndex("POST /instances") + require.GreaterOrEqual(t, volumeCreate, 0) + require.GreaterOrEqual(t, instanceCreate, 0) + assert.Less(t, volumeCreate, instanceCreate) + + vol := fake.onlyVolume() + assert.Equal(t, "stateful-data", vol.name) + inst := fake.onlyInstance() + require.Len(t, inst.volumes, 1) + assert.Equal(t, vol.id, inst.volumes[0].VolumeID) + assert.Equal(t, "/var/lib/postgresql/data", inst.volumes[0].MountPath) + + // The guest writes a nonce to its volume. + vol.data = "nonce-abc123" + + // Down retains the volume by default and says so in the plan. + downPlan, err := newTestRunner(t, composePath, server).Down(ctx, DownOptions{}) + require.NoError(t, err) + assert.Empty(t, fake.instances) + require.Equal(t, 1, fake.volumeCount()) + var retainAction *Action + for i := range downPlan.Actions { + if downPlan.Actions[i].Type == "volume" { + retainAction = &downPlan.Actions[i] + } + } + require.NotNil(t, retainAction) + assert.Equal(t, "skip", retainAction.Action) + assert.Contains(t, retainAction.Reason, "retained") + + // Up again reuses the retained volume: no new volume is created and the + // new instance mounts the same volume ID, so the nonce survives down/up. + volumeCreatesBefore := fake.countRequests("POST /volumes") + _, err = newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + assert.Equal(t, volumeCreatesBefore, fake.countRequests("POST /volumes")) + inst = fake.onlyInstance() + require.Len(t, inst.volumes, 1) + assert.Equal(t, vol.id, inst.volumes[0].VolumeID) + assert.Equal(t, "nonce-abc123", fake.onlyVolume().data) +} + +func TestComposeReplaceRetainsVolumeAndNonce(t *testing.T) { + fake, server := newFakeHypeman(t) + dir := t.TempDir() + composePath := writeComposeFile(t, dir, statefulComposeFile) + ctx := context.Background() + + _, err := newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + vol := fake.onlyVolume() + vol.data = "nonce-persist-me" + firstInstanceID := fake.onlyInstance().id + + // Change the rendered spec so the instance must be replaced. + writeComposeFile(t, dir, ` +version: 1 +name: stateful +volumes: + data: + size_gb: 5 +services: + db: + image: postgres:16 + env: + POSTGRES_PASSWORD: changed + volumes: + - data:/var/lib/postgresql/data +`) + + // Without --replace, up refuses and touches nothing. + _, err = newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.ErrorContains(t, err, "replace required") + assert.Equal(t, firstInstanceID, fake.onlyInstance().id) + + plan, err := newTestRunner(t, composePath, server).Up(ctx, UpOptions{Replace: true}) + require.NoError(t, err) + assert.Equal(t, 1, plan.Summary.Replace) + + // The replacement instance mounts the same retained volume; the nonce + // survives replacement and the volume itself was never recreated. + inst := fake.onlyInstance() + assert.NotEqual(t, firstInstanceID, inst.id) + require.Len(t, inst.volumes, 1) + assert.Equal(t, vol.id, inst.volumes[0].VolumeID) + require.Equal(t, 1, fake.volumeCount()) + assert.Equal(t, "nonce-persist-me", fake.onlyVolume().data) + assert.Equal(t, 0, fake.countRequests("DELETE /volumes")) +} + +func TestComposeReplaceFailureLeavesVolumeRecoverable(t *testing.T) { + fake, server := newFakeHypeman(t) + dir := t.TempDir() + composePath := writeComposeFile(t, dir, statefulComposeFile) + ctx := context.Background() + + _, err := newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + vol := fake.onlyVolume() + vol.data = "nonce-recover-me" + + writeComposeFile(t, dir, ` +version: 1 +name: stateful +volumes: + data: + size_gb: 5 +services: + db: + image: postgres:16 + env: + POSTGRES_PASSWORD: changed + volumes: + - data:/var/lib/postgresql/data +`) + + // The old instance is deleted but creation of the replacement fails. + // (The SDK retries 5xx, so fail enough times to exhaust retries.) + fake.failInstanceCreates = 10 + _, err = newTestRunner(t, composePath, server).Up(ctx, UpOptions{Replace: true}) + require.Error(t, err) + assert.Empty(t, fake.instances) + + // The retained volume and its data are untouched and recoverable. + require.Equal(t, 1, fake.volumeCount()) + assert.Equal(t, "nonce-recover-me", fake.onlyVolume().data) + + // Retrying up recreates the instance on the same retained volume. + fake.failInstanceCreates = 0 + _, err = newTestRunner(t, composePath, server).Up(ctx, UpOptions{Replace: true}) + require.NoError(t, err) + inst := fake.onlyInstance() + require.Len(t, inst.volumes, 1) + assert.Equal(t, vol.id, inst.volumes[0].VolumeID) + assert.Equal(t, "nonce-recover-me", fake.onlyVolume().data) +} + +func TestComposeDownVolumesDeletesRetainedData(t *testing.T) { + fake, server := newFakeHypeman(t) + dir := t.TempDir() + composePath := writeComposeFile(t, dir, statefulComposeFile) + ctx := context.Background() + + _, err := newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + fake.onlyVolume().data = "nonce-doomed" + + plan, err := newTestRunner(t, composePath, server).Down(ctx, DownOptions{Volumes: true}) + require.NoError(t, err) + assert.Empty(t, fake.instances) + assert.Empty(t, fake.volumes) + var volumeDelete *Action + for i := range plan.Actions { + if plan.Actions[i].Type == "volume" { + volumeDelete = &plan.Actions[i] + } + } + require.NotNil(t, volumeDelete) + assert.Equal(t, "delete", volumeDelete.Action) + assert.Contains(t, volumeDelete.Reason, "destroys retained data") + + // Down is idempotent: running it again finds nothing. + plan, err = newTestRunner(t, composePath, server).Down(ctx, DownOptions{Volumes: true}) + require.NoError(t, err) + for _, action := range plan.Actions { + assert.Equal(t, "skip", action.Action) + assert.Equal(t, "not found", action.Reason) + } +} + +func TestComposeVolumeSpecChangeConflictsWithoutTouchingData(t *testing.T) { + fake, server := newFakeHypeman(t) + dir := t.TempDir() + composePath := writeComposeFile(t, dir, statefulComposeFile) + ctx := context.Background() + + _, err := newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + fake.onlyVolume().data = "nonce-safe" + + // Changing the declared volume size must not resize/replace the volume. + writeComposeFile(t, dir, strings.Replace(statefulComposeFile, "size_gb: 5", "size_gb: 10", 1)) + plan, err := newTestRunner(t, composePath, server).Plan(ctx) + require.NoError(t, err) + assert.Equal(t, 1, plan.Summary.Conflict) + + _, err = newTestRunner(t, composePath, server).Up(ctx, UpOptions{Replace: true}) + require.ErrorContains(t, err, "conflicts found") + require.Equal(t, 1, fake.volumeCount()) + assert.Equal(t, int64(5), fake.onlyVolume().sizeGB) + assert.Equal(t, "nonce-safe", fake.onlyVolume().data) + assert.Equal(t, 0, fake.countRequests("DELETE /volumes")) +} + +func TestComposeUpPrunesRemovedOwnedResourcesOnly(t *testing.T) { + fake, server := newFakeHypeman(t) + dir := t.TempDir() + composePath := writeComposeFile(t, dir, ` +version: 1 +name: stateful +volumes: + data: + size_gb: 5 +services: + db: + image: postgres:16 + volumes: + - data:/var/lib/postgresql/data + cache: + image: redis:7 +`) + ctx := context.Background() + + _, err := newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + require.Len(t, fake.instances, 2) + + // An unmanaged instance with no compose ownership tags exists alongside. + fake.mu.Lock() + fake.instances["unmanaged-1"] = &fakeInstance{id: "unmanaged-1", name: "stateful-cache-lookalike", image: "redis:7"} + fake.mu.Unlock() + + // Remove the cache service from the file. + writeComposeFile(t, dir, statefulComposeFile) + plan, err := newTestRunner(t, composePath, server).Plan(ctx) + require.NoError(t, err) + var pruned []Action + for _, action := range plan.Actions { + if action.Action == "delete" { + pruned = append(pruned, action) + } + } + require.Len(t, pruned, 1) + assert.Equal(t, "instance", pruned[0].Type) + assert.Equal(t, "stateful-cache", pruned[0].Name) + assert.Equal(t, "no longer declared in compose file", pruned[0].Reason) + + _, err = newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + fake.mu.Lock() + _, cacheExists := fake.instancesByName("stateful-cache") + _, unmanagedAlive := fake.instances["unmanaged-1"] + dbCount := len(fake.instances) + fake.mu.Unlock() + assert.False(t, cacheExists) + assert.True(t, unmanagedAlive, "unmanaged instance must not be pruned") + assert.Equal(t, 2, dbCount) // db instance + unmanaged +} + +func TestComposeUpPrunesBeforeCreatesSoMovedHostnameDoesNotWedge(t *testing.T) { + fake, server := newFakeHypeman(t) + dir := t.TempDir() + composePath := writeComposeFile(t, dir, ` +version: 1 +name: web +services: + old: + image: nginx:1 + ingress: + - hostname: app.example.com + target_port: 80 +`) + ctx := context.Background() + + _, err := newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + require.Len(t, fake.ingresses, 1) + + // Move the hostname to a replacement service: the old service (and its + // owned ingress holding app.example.com) is pruned while the new service + // creates an ingress reusing the same hostname. Prune deletes must free + // the hostname before the create runs, or up fails deterministically. + writeComposeFile(t, dir, ` +version: 1 +name: web +services: + new: + image: nginx:1 + ingress: + - hostname: app.example.com + target_port: 80 +`) + requestsBefore := fake.requestCount() + plan, err := newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + assert.Equal(t, 2, plan.Summary.Delete) // old instance + old ingress + + ingressDelete := fake.requestIndexAfter("DELETE /ingresses/", requestsBefore) + ingressCreate := fake.requestIndexAfter("POST /ingresses", requestsBefore) + require.GreaterOrEqual(t, ingressDelete, 0) + require.GreaterOrEqual(t, ingressCreate, 0) + assert.Less(t, ingressDelete, ingressCreate, "pruned ingress must be deleted before the new ingress is created") + + // Plan order matches apply order: deletes precede creates. + seenCreate := false + for _, action := range plan.Actions { + if action.Action == "create" && (action.Type == "instance" || action.Type == "ingress") { + seenCreate = true + } + if action.Action == "delete" { + assert.False(t, seenCreate, "prune delete %s %s planned after a create", action.Type, action.Name) + } + } + + // Only the new service's resources remain, and re-running up is stable. + fake.mu.Lock() + _, oldAlive := fake.instancesByName("web-old") + fake.mu.Unlock() + assert.False(t, oldAlive) + require.Len(t, fake.ingresses, 1) + for _, ing := range fake.ingresses { + assert.Equal(t, "web-new-0", ing.name) + } + _, err = newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) +} + +func TestComposePlanConflictDoesNotPlanPruneForAmbiguousRenameCandidates(t *testing.T) { + fake, server := newFakeHypeman(t) + dir := t.TempDir() + composePath := writeComposeFile(t, dir, ` +version: 1 +name: app +services: + api: + image: svc:1 + ingress: + - name: app-api-http + hostname: http.example.com + target_port: 80 + - name: app-api-grpc + hostname: grpc.example.com + target_port: 81 +`) + ctx := context.Background() + + _, err := newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + require.Len(t, fake.ingresses, 2) + + // Collapsing both rules into one renamed rule makes both owned ingresses + // rename candidates for the same desired ingress: an ambiguous conflict. + writeComposeFile(t, dir, ` +version: 1 +name: app +services: + api: + image: svc:1 + ingress: + - name: app-api-public + hostname: public.example.com + target_port: 80 +`) + plan, err := newTestRunner(t, composePath, server).Plan(ctx) + require.NoError(t, err) + assert.Equal(t, 1, plan.Summary.Conflict) + for _, action := range plan.Actions { + assert.NotEqual(t, "delete", action.Action, + "conflicted rename candidates must not also be planned for deletion: %s %s", action.Type, action.Name) + } + + // Up refuses and touches nothing. + _, err = newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.ErrorContains(t, err, "conflicts found") + assert.Equal(t, 0, fake.countRequests("DELETE /ingresses/")) + require.Len(t, fake.ingresses, 2) +} + +func TestComposeUpSharesVolumeLookupAcrossInstanceCreates(t *testing.T) { + fake, server := newFakeHypeman(t) + dir := t.TempDir() + composePath := writeComposeFile(t, dir, ` +version: 1 +name: stateful +volumes: + data: + size_gb: 5 + logs: + size_gb: 1 +services: + db: + image: postgres:16 + volumes: + - data:/var/lib/postgresql/data + worker: + image: worker:1 + volumes: + - data:/var/lib/data + - logs:/var/log/worker +`) + ctx := context.Background() + + _, err := newTestRunner(t, composePath, server).Up(ctx, UpOptions{}) + require.NoError(t, err) + require.Len(t, fake.instances, 2) + + // Plan lists volumes twice (owned + all). The apply pass resolves volume + // names for both instance creates from a single shared list, not one + // list per instance create. + assert.Equal(t, 3, fake.countRequests("GET /volumes")) +} + +func (f *fakeHypeman) instancesByName(name string) (*fakeInstance, bool) { + for _, inst := range f.instances { + if inst.name == name { + return inst, true + } + } + return nil, false +} diff --git a/pkg/cmd/composecmd.go b/pkg/cmd/composecmd.go index 8ba3219..e609c56 100644 --- a/pkg/cmd/composecmd.go +++ b/pkg/cmd/composecmd.go @@ -88,13 +88,21 @@ var composeUpCmd = cli.Command{ var composeDownCmd = cli.Command{ Name: "down", Usage: "Delete resources owned by a compose file", - Flags: composeFileFlags(), + Flags: append(composeFileFlags(), + &cli.BoolFlag{ + Name: "volumes", + Usage: "Also delete retained volumes owned by the compose file (destroys their data)", + }, + ), 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") + result, err := runner.Down(ctx, compose.DownOptions{ + Verbose: cmd.Root().String("format") == "auto", + Volumes: cmd.Bool("volumes"), + }) if err != nil { return err }