package apply import ( "context" "errors" "strings" "testing" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) // Issue 1742: the engine removed a running container and only then found the replacement image // unobtainable, leaving the service down. The replacement image comes first. func TestARunningContainerIsKeptWhenItsReplacementImageCannotBeObtained(t *testing.T) { d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}} ]}`) var removed, created bool run := func(ctx context.Context, name string, args ...string) (string, error) { args = oldShape(name, args) switch args[0] { case "info": return "27.0\n", nil case "container": return "true\tsome-older-spec", nil case "image": return "", errors.New("no such image") case "pull": return "", errors.New("manifest unknown") case "rm": removed = true return "", nil case "run": created = true return "deadbeef\n", nil } return "", nil } _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil) if removed || created { t.Fatalf("the running container was touched although its image is unobtainable (removed=%v created=%v)", removed, created) } if err == nil { t.Fatal("an unobtainable image failed nothing") } if !strings.Contains(err.Error(), pinned) { t.Errorf("the failure does not name the image %s: %v", pinned, err) } } func TestAReplacementImageIsPresentBeforeTheRunningContainerIsRemoved(t *testing.T) { d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}} ]}`) want := containerSpec(d.Resources[0].(*declaration.Container), inputs{}) var order []string created := false run := func(ctx context.Context, name string, args ...string) (string, error) { args = oldShape(name, args) switch args[0] { case "info": return "27.0\n", nil case "container": if created { return "true\t" + want, nil } return "true\tsome-older-spec", nil case "image": order = append(order, "inspect") if len(order) == 1 { return "", errors.New("no such image") } return "", nil case "pull": order = append(order, "pull") case "rm": order = append(order, "rm") case "run": order = append(order, "run") created = true return "deadbeef\n", nil } return "", nil } if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil); err != nil { t.Fatalf("apply failed: %v", err) } got := strings.Join(order, ",") if !strings.HasPrefix(got, "inspect,pull,inspect,rm,run") { t.Errorf("order of runtime calls = %s, want the image pulled before rm", got) } } // asideRun is a runtime whose container "store" runs from an older spec, recording the steps a replacement takes; run // fails when failRun, and the new container reads back as declared otherwise. func asideRun(t *testing.T, want string, failRun bool, order *[]string) Runner { created := false return func(ctx context.Context, name string, args ...string) (string, error) { switch args[0] { case "info": return "27.0\n", nil case "container": if created { return "true\t" + want, nil } return "true\tsome-older-spec", nil case "image": return "", nil case "rm", "rename", "stop", "start": *order = append(*order, strings.Join(args, " ")) return "", nil case "run": *order = append(*order, "run") if failRun { return "", errors.New("port is already allocated") } created = true return "deadbeef\n", nil } return "", nil } } // A container whose replacement does not come up is put back: the running one is set aside (renamed and stopped), never // removed before the new one runs, and started again under its name when the new one fails, the apply failing loudly. // Removed first, the bus's own container left novox without a bus when its replacement was never made (issue 1945, // live on 2026-10-25 at 11:23). func TestAContainerWhoseReplacementFailsIsPutBack(t *testing.T) { d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}} ]}`) want := containerSpec(d.Resources[0].(*declaration.Container), inputs{}) var order []string _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, asideRun(t, want, true, &order), nil, nil) if err == nil || !strings.Contains(err.Error(), "started again") { t.Fatalf("a failed replacement: %v", err) } got := strings.Join(order, "; ") if !strings.Contains(got, "rename store store.replaced; stop store.replaced; run") || !strings.Contains(got, "rename store.replaced store; start store") { t.Fatalf("the steps were %q", got) } if strings.Contains(got, "rm -f store;") && strings.Index(got, "rm -f store;") < strings.Index(got, "run") { t.Fatalf("the running container was removed before its replacement ran: %q", got) } } // A replacement that runs removes the container set aside, and only after it runs. func TestAReplacementThatRunsRemovesTheContainerSetAside(t *testing.T) { d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}} ]}`) want := containerSpec(d.Resources[0].(*declaration.Container), inputs{}) var order []string if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, asideRun(t, want, false, &order), nil, nil); err != nil { t.Fatal(err) } got := strings.Join(order, "; ") run, removed := strings.Index(got, "run"), strings.LastIndex(got, "rm -f store.replaced") if run < 0 || removed < run || strings.Contains(got, "start store") { t.Fatalf("the steps were %q: the set-aside container is removed after the replacement runs", got) } } // A container left set aside by an engine that stopped between setting it aside and running its replacement is the // set-aside one of the next apply: put back when the replacement fails, removed once it runs (the review of issue // 1945). func TestAContainerLeftAsideIsPutBackOrRemovedByTheNextApply(t *testing.T) { d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}} ]}`) want := containerSpec(d.Resources[0].(*declaration.Container), inputs{}) for _, failRun := range []bool{true, false} { var order []string created := false run := func(ctx context.Context, name string, args ...string) (string, error) { switch args[0] { case "info": return "27.0\n", nil case "container": switch args[len(args)-1] { case "store": if created { return "true\t" + want, nil } return "", errors.New("no such container") case "store.replaced": return "false\tsome-older-spec", nil } return "", errors.New("no such container") case "image": return "", nil case "rm", "rename", "stop", "start": order = append(order, strings.Join(args, " ")) return "", nil case "run": order = append(order, "run") if failRun { return "", errors.New("port is already allocated") } created = true return "deadbeef\n", nil } return "", nil } _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil) got := strings.Join(order, "; ") if failRun { if err == nil || !strings.Contains(got, "rename store.replaced store; start store") { t.Fatalf("a failed run beside a container left aside: %v; steps %q", err, got) } continue } if err != nil || !strings.Contains(got, "run; rm -f store.replaced") { t.Fatalf("a run beside a container left aside: %v; steps %q", err, got) } } }