Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions internal/dockerlab/dockerlab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"):
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion internal/dockerlab/janitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
18 changes: 14 additions & 4 deletions internal/dockerlab/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
83 changes: 83 additions & 0 deletions internal/dockerlab/network_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading