diff --git a/internal/dockerlab/dockerlab_test.go b/internal/dockerlab/dockerlab_test.go index 0bb0042..2efa9bb 100644 --- a/internal/dockerlab/dockerlab_test.go +++ b/internal/dockerlab/dockerlab_test.go @@ -71,6 +71,21 @@ type fakeDockerRunner struct { networkOrder []string networkCreates int failNetworkCreate bool + // contextFailure makes every invocation fail the way the Docker CLI does + // when the selected context cannot be resolved. + contextFailure bool + // contextFailureOnNetworkRemove fails only network removal that way. + contextFailureOnNetworkRemove bool +} + +// contextNotFoundMessage is the Docker CLI's output when the selected +// context is missing, captured from Docker 29.7.2. +const contextNotFoundMessage = `Failed to initialize: unable to resolve docker endpoint: context "opsquest-missing-context": context not found: open /home/player/.docker/contexts/meta/fdff/meta.json: no such file or directory` + +func (r *fakeDockerRunner) setContextFailure(failing bool) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.contextFailure = failing } func newFakeDockerRunner() *fakeDockerRunner { @@ -92,6 +107,9 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, return runResult{}, err } r.calls = append(r.calls, append([]string(nil), args...)) + if r.contextFailure { + return runResult{stderr: contextNotFoundMessage}, errors.New("exit status 1") + } switch { case hasPrefix(args, "context", "show"): @@ -345,6 +363,8 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, } } return runResult{stdout: output.String()}, nil + case hasPrefix(args, "network", "rm") && r.contextFailureOnNetworkRemove: + return runResult{stderr: contextNotFoundMessage}, errors.New("exit status 1") case hasPrefix(args, "network", "rm"): id := args[len(args)-1] network, exists := r.networks[id] diff --git a/internal/dockerlab/janitor.go b/internal/dockerlab/janitor.go index 7610c31..e16818c 100644 --- a/internal/dockerlab/janitor.go +++ b/internal/dockerlab/janitor.go @@ -67,7 +67,7 @@ func (f *Factory) removeOrphans(ctx context.Context) (int, error) { } for _, id := range orphans.networks { result, err := runDocker(ctx, f.runner, "network", "rm", id) - if err != nil && !isMissingNetwork(result) { + if err != nil && !isMissingNetwork(result, id) { removeErrors = append(removeErrors, fmt.Errorf("remove orphaned Docker network %s: %w", id[:12], err)) continue } diff --git a/internal/dockerlab/network.go b/internal/dockerlab/network.go index 6920b7c..9e9cd6b 100644 --- a/internal/dockerlab/network.go +++ b/internal/dockerlab/network.go @@ -145,7 +145,7 @@ func (e *environment) inspectNetworkUnchecked(ctx context.Context, network *trac func inspectNetworkReference(ctx context.Context, commandRunner runner, reference string) (networkInspection, bool, error) { result, err := runDocker(ctx, commandRunner, "network", "inspect", "--format", "{{json .}}", reference) if err != nil { - if isMissingNetwork(result) { + if isMissingNetwork(result, reference) { return networkInspection{}, false, nil } return networkInspection{}, false, err @@ -157,9 +157,19 @@ func inspectNetworkReference(ctx context.Context, commandRunner runner, referenc return inspection, true, nil } -func isMissingNetwork(result runResult) bool { +// isMissingNetwork reports whether Docker said that exactly reference does +// not exist. It accepts only the engine's network-specific wording that names +// the reference, so CLI, context, and transport failures such as +// `context "x": context not found` propagate as errors instead of looking +// like a removed network. +func isMissingNetwork(result runResult, reference string) bool { + if reference == "" { + return false + } message := strings.ToLower(result.stderr + "\n" + result.stdout) - return strings.Contains(message, "no such network") || strings.Contains(message, "not found") + reference = strings.ToLower(reference) + return strings.Contains(message, "network "+reference+" not found") || + strings.Contains(message, "no such network: "+reference) } // attachedTo reports whether a container inspection shows an endpoint on @@ -477,7 +487,7 @@ func (e *environment) closeNetworks(ctx context.Context, networks []*trackedNetw continue } network.id = inspection.ID - if result, err := e.run(ctx, "network", "rm", inspection.ID); err != nil && !isMissingNetwork(result) { + if result, err := e.run(ctx, "network", "rm", inspection.ID); err != nil && !isMissingNetwork(result, inspection.ID) { cleanupErrors = append(cleanupErrors, fmt.Errorf("remove Docker network %s: %w", network.name, err)) unresolved = append(unresolved, network) } diff --git a/internal/dockerlab/network_test.go b/internal/dockerlab/network_test.go index 2cc72bb..b00d371 100644 --- a/internal/dockerlab/network_test.go +++ b/internal/dockerlab/network_test.go @@ -391,3 +391,86 @@ func TestNetworkErrorsRedactExactIdentity(t *testing.T) { t.Fatalf("redactNetworkIdentifiers() = %q", got) } } + +func TestMissingNetworkClassificationIsReferenceSpecific(t *testing.T) { + name := "opsquest-" + strings.Repeat("a", 24) + "-n01" + id := "feed" + strings.Repeat("0", 59) + "1" + tests := []struct { + name string + stderr string + reference string + want bool + }{ + {name: "daemon missing name", stderr: "Error response from daemon: network " + name + " not found", reference: name, want: true}, + {name: "daemon missing ID", stderr: "Error response from daemon: network " + id + " not found", reference: id, want: true}, + {name: "legacy CLI wording", stderr: "Error: No such network: " + name, reference: name, want: true}, + {name: "context not found", stderr: contextNotFoundMessage, reference: name}, + {name: "different network", stderr: "Error response from daemon: network other-net not found", reference: name}, + {name: "daemon unreachable", stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", reference: name}, + {name: "empty reference", stderr: "Error response from daemon: network not found", reference: ""}, + } + for _, test := range tests { + if got := isMissingNetwork(runResult{stderr: test.stderr}, test.reference); got != test.want { + t.Errorf("%s: isMissingNetwork() = %v, want %v", test.name, got, test.want) + } + } +} + +func TestContextFailureNeitherPassesValidationNorDropsNetworkCleanup(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createEnvironmentForMission(t, commandRunner, testNetworkDockerMission()) + networks := commandRunner.networkCount() + if networks != 3 { + t.Fatalf("fixture networks = %d, want 3", networks) + } + + commandRunner.setContextFailure(true) + absent := mission.Condition{Type: mission.ConditionDockerNetworkAbsent, Network: "db-net"} + if got, err := environment.Observe(context.Background(), absent); err == nil || got || !strings.Contains(err.Error(), "context not found") { + t.Fatalf("Observe(network absent) during context failure = %v, %v; want error", got, err) + } + shared := networkCondition(mission.ConditionDockerNetworkShared, "web", "api") + if got, err := environment.Observe(context.Background(), shared); err == nil || got { + t.Fatalf("Observe(shared) during context failure = %v, %v; want error", got, err) + } + if err := environment.Close(); err == nil { + t.Fatal("Close() during context failure reported success") + } + if commandRunner.networkCount() != networks { + t.Fatalf("networks changed during failed Close: %d", commandRunner.networkCount()) + } + + commandRunner.setContextFailure(false) + if err := environment.Close(); err != nil { + t.Fatalf("Close() after context recovery = %v", err) + } + if commandRunner.networkCount() != 0 || commandRunner.containerCount() != 0 { + t.Fatalf("resources leaked after recovered Close: %d networks, %d containers", commandRunner.networkCount(), commandRunner.containerCount()) + } +} + +func TestJanitorPropagatesNetworkRemovalFailures(t *testing.T) { + commandRunner := newFakeDockerRunner() + session := strings.Repeat("1", 24) + orphan := "feed" + strings.Repeat("1", 60) + commandRunner.seedNetwork(orphan, &fakeNetwork{labels: orphanLabels(session, "999", testOwnerHost), name: "opsquest-" + session + "-n01", created: testNow}) + factory := testFactory(commandRunner) + + commandRunner.mutex.Lock() + commandRunner.contextFailureOnNetworkRemove = true + commandRunner.mutex.Unlock() + removed, err := factory.RemoveOrphanedResources(context.Background()) + if err == nil || !strings.Contains(err.Error(), "context not found") || removed != 0 { + t.Fatalf("RemoveOrphanedResources() during context failure = %d, %v; want error and nothing counted", removed, err) + } + if !commandRunner.hasNetwork(orphan) { + t.Fatal("orphaned network disappeared during a failed removal") + } + + commandRunner.mutex.Lock() + commandRunner.contextFailureOnNetworkRemove = false + commandRunner.mutex.Unlock() + if removed, err := factory.RemoveOrphanedResources(context.Background()); err != nil || removed != 1 || commandRunner.hasNetwork(orphan) { + t.Fatalf("RemoveOrphanedResources() after recovery = %d, %v", removed, err) + } +}