diff --git a/internal/compose/resolution/declaration.go b/internal/compose/resolution/declaration.go index 6f0f165..951883d 100644 --- a/internal/compose/resolution/declaration.go +++ b/internal/compose/resolution/declaration.go @@ -147,7 +147,7 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string] // Before placing, because a placement is a setting too. left := LeftOut(r, with.Settings, with.Adopted) kept := make([]mcat.Manifest, 0, len(r.Modules)) - var stillBackedUp []mcat.Manifest + var stillBackedUp, stillRunning []mcat.Manifest for module, why := range with.NotPlaced { if _, already := left[module]; !already { left[module] = why @@ -162,6 +162,10 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string] // the data it already holds on the machine is the reason to keep copying it. Only its data, // as the backup holder's lines are derived from it, and the directories they name. stillBackedUp = append(stillBackedUp, backupView(m)) + // **And what it runs is still let through** (issue 1966): its containers and services are left as they run, so + // the filter keeps opening what its listens say, at the machine ports this node holds for it, rather than + // closing a port that still serves (the artifact store's 5100 on the control-node, 98 minutes). + stillRunning = append(stillRunning, m) continue } kept = append(kept, m) @@ -255,7 +259,9 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string] // Once, from every module's listens -- not per module. A module receiving only its own ports // would write a rule set that closed every other module on the machine. Each module's per-node // exposure settings override its listens' source first (novox/hq ADR 0046). - rules, err := Rules(r, with) + filtered := r + filtered.Modules = append(slices.Clone(r.Modules), stillRunning...) + rules, err := Rules(filtered, with) if err != nil { return nil, err } @@ -1242,8 +1248,9 @@ func resolutionFiltersHere(r mcat.Resolution) string { // is adopted or converged — the one loads it as a filter, the other declares it as openings. func Rules(r mcat.Resolution, with mcat.Rendering) ([]mcat.Rule, error) { // A module whose settings cannot compose is left out of the declaration (novox/hq ADR 0163, - // rule 6), and out of the filter with it: nothing of it is declared, so nothing of it is let - // through. + // rule 6), and its settings with it: its per-node exposure is not read, and its listens open + // as its manifest says them. It is not left out of the filter: what it runs is left running, + // so a port it serves stays open (issue 1966). left := LeftOut(r, with.Settings, with.Adopted) exposure := map[string]map[int]string{} for _, m := range r.Modules { diff --git a/internal/compose/resolution/filtering_test.go b/internal/compose/resolution/filtering_test.go index e8976c1..be43cc9 100644 --- a/internal/compose/resolution/filtering_test.go +++ b/internal/compose/resolution/filtering_test.go @@ -60,6 +60,33 @@ func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) { if !strings.Contains(content, "dport 443 accept") { t.Fatalf("the rule set holds only the asking module's ports:\n%s", content) } + + // Issue 1966, novox 2026-11-09 07:46-09:24 UTC: a module left out of the declaration for a setting its new definition + // no longer composes (ADR 0163 rule 6) keeps running what it ran, so the filter keeps letting it through. The + // artifact store's build 6e1dfaa8 listens on 5100 from a process and publishes no container port 5000, while its + // node layer still gave 5000 the machine port 5100: left out, and the filter closed 5100 for 98 minutes while the old + // registry container still served there; every build failed "dial tcp 10.10.0.1:5100: i/o timeout". + r.Modules = append(r.Modules, mcat.Manifest{Module: "distribution", + Listens: []mcat.Listening{{Name: "registry", Port: 5100, Protocol: "tcp", From: mcat.FromEverywhere}}, + Resources: []map[string]any{{"id": "store", "type": "container", "name": "mesh-registry", + "image": "registry@sha256:" + strings.Repeat("a", 64), "ports": []any{"127.0.0.1:5001:5001"}}}}) + with := mcat.Rendering{Mesh: []string{"198.51.100.2"}, OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0", + Settings: mcat.SettingsBy{"distribution": {{From: "novox", Placement: map[string]any{mcat.PortsSetting: map[string]any{"5000": 5100}}}}}} + if left := LeftOut(r, with.Settings, false); left["distribution"] == "" { + t.Fatalf("the stale port setting does not leave distribution out: %v", left) + } + if out, err = Declaration(r, with); err != nil { + t.Fatalf("declaration with a module left out: %v", err) + } + content = "" + for _, res := range out { + if res["path"] == "/etc/mesh/filter.nft" { + content, _ = res["content"].(string) + } + } + if !strings.Contains(content, "dport 5100 accept") || !strings.Contains(content, "dport 443 accept") { + t.Fatalf("the filter closes the port of a module left out of the declaration, which still runs:\n%s", content) + } } // The host does not sort, so the order the mesh writes is the order a machine applies.