v3.1.0 d676137

runtime_recovery_test.go RawHistoryFolder

1package docker
2
3import (
4	"context"
5	"errors"
6	"io"
7	"net/http"
8	"net/http/httptest"
9	"strconv"
10	"strings"
11	"testing"
12	"time"
13
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16
17	"github.com/bnema/gordon/internal/domain"
18)
19
20// TestRuntime_InspectContainerReportsNotFoundSentinel proves a missing
21// container is distinguishable from a transient runtime failure with
22// errors.Is, so recovery never treats an API outage as a dead workload.
23func TestRuntime_InspectContainerReportsNotFoundSentinel(t *testing.T) {
24	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
25		w.Header().Set("Content-Type", "application/json")
26		w.WriteHeader(http.StatusNotFound)
27		_, _ = w.Write([]byte(`{"message":"No such container: gone"}`))
28	}))
29	defer server.Close()
30
31	runtime := newRuntimeForHTTPServer(t, server)
32	_, err := runtime.InspectContainer(context.Background(), "gone")
33	require.Error(t, err)
34	assert.ErrorIs(t, err, domain.ErrContainerNotFound)
35}
36
37// TestRuntime_InspectContainerPreservesTransientErrors proves a non-404
38// failure is not reported as a missing container.
39func TestRuntime_InspectContainerPreservesTransientErrors(t *testing.T) {
40	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
41		w.Header().Set("Content-Type", "application/json")
42		w.WriteHeader(http.StatusInternalServerError)
43		_, _ = w.Write([]byte(`{"message":"server error"}`))
44	}))
45	defer server.Close()
46
47	runtime := newRuntimeForHTTPServer(t, server)
48	_, err := runtime.InspectContainer(context.Background(), "c-1")
49	require.Error(t, err)
50	assert.NotErrorIs(t, err, domain.ErrContainerNotFound)
51}
52
53// TestRuntime_InspectContainerNormalizesStatusAndExecutionStart proves
54// the state a recovery pass classifies on (running/exited/restarting/
55// paused) and the current execution start used to scope log readiness.
56func TestRuntime_InspectContainerNormalizesStatusAndExecutionStart(t *testing.T) {
57	started := "2026-09-10T12:00:00.123456789Z"
58	statuses := []string{"running", "exited", "restarting", "paused", "created"}
59	for _, status := range statuses {
60		t.Run(status, func(t *testing.T) {
61			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
62				w.Header().Set("Content-Type", "application/json")
63				_, _ = w.Write([]byte(`{
64					"Id":"c-1",
65					"Created":"2026-09-10T11:59:00Z",
66					"State":{"Status":"` + status + `","ExitCode":1,"StartedAt":"` + started + `"},
67					"Config":{"Image":"img:1"}
68				}`))
69			}))
70			defer server.Close()
71
72			runtime := newRuntimeForHTTPServer(t, server)
73			container, err := runtime.InspectContainer(context.Background(), "c-1")
74			require.NoError(t, err)
75			assert.Equal(t, status, container.Status)
76			assert.Equal(t, 1, container.ExitCode, "a non-running container keeps its exit code")
77			assert.Equal(t, time.Date(2026, 9, 10, 12, 0, 0, 123456789, time.UTC), container.StartedAt.UTC())
78		})
79	}
80}
81
82// TestRuntime_StartContainerReportsNotFoundSentinel proves the
83// native-restart race can be told apart from a real start failure.
84func TestRuntime_StartContainerReportsNotFoundSentinel(t *testing.T) {
85	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
86		w.Header().Set("Content-Type", "application/json")
87		w.WriteHeader(http.StatusNotFound)
88		_, _ = w.Write([]byte(`{"message":"No such container: gone"}`))
89	}))
90	defer server.Close()
91
92	runtime := newRuntimeForHTTPServer(t, server)
93	err := runtime.StartContainer(context.Background(), "gone")
94	require.Error(t, err)
95	assert.ErrorIs(t, err, domain.ErrContainerNotFound)
96}
97
98// TestRuntime_GetContainerLogsSinceScopesTheRequest proves readiness
99// reads one exact container and passes the execution boundary to the
100// runtime, so markers from a previous execution cannot match.
101func TestRuntime_GetContainerLogsSinceScopesTheRequest(t *testing.T) {
102	// Sub-second boundary: a marker from the same second as a previous
103	// execution must not pass the filter.
104	since := time.Date(2026, 9, 10, 12, 0, 0, 123456789, time.UTC)
105	var gotPath, gotSince, gotTail string
106	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107		gotPath = r.URL.Path
108		gotSince = r.URL.Query().Get("since")
109		gotTail = r.URL.Query().Get("tail")
110		w.Header().Set("Content-Type", "application/vnd.docker.raw-stream")
111		_, _ = w.Write([]byte("2026-09-10T12:00:01Z ready\n"))
112	}))
113	defer server.Close()
114
115	runtime := newRuntimeForHTTPServer(t, server)
116	stream, err := runtime.GetContainerLogsSince(context.Background(), "c-exact", since, false)
117	require.NoError(t, err)
118	defer func() { _ = stream.Close() }()
119	body, err := io.ReadAll(stream)
120	require.NoError(t, err)
121	assert.Contains(t, string(body), "ready")
122
123	assert.True(t, strings.HasSuffix(gotPath, "/containers/c-exact/logs"), "logs are read from the exact container ID")
124	// The Docker API takes the boundary as unix seconds with nanoseconds.
125	wireSince, parseErr := strconv.ParseFloat(gotSince, 64)
126	require.NoError(t, parseErr)
127	wantSince := float64(since.UnixNano()) / 1e9
128	assert.InDelta(t, wantSince, wireSince, 1e-6, "the sub-second execution boundary reaches the runtime unchanged")
129	assert.Equal(t, "10000", gotTail)
130}
131
132// TestRuntime_GetContainerLogsSinceReportsNotFoundSentinel proves a
133// removed candidate fails readiness with an actionable error instead of
134// hanging until the deadline.
135func TestRuntime_GetContainerLogsSinceReportsNotFoundSentinel(t *testing.T) {
136	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
137		w.Header().Set("Content-Type", "application/json")
138		w.WriteHeader(http.StatusNotFound)
139		_, _ = w.Write([]byte(`{"message":"No such container: gone"}`))
140	}))
141	defer server.Close()
142
143	runtime := newRuntimeForHTTPServer(t, server)
144	_, err := runtime.GetContainerLogsSince(context.Background(), "gone", time.Now(), false)
145	require.Error(t, err)
146	assert.ErrorIs(t, err, domain.ErrContainerNotFound)
147	assert.False(t, errors.Is(err, context.DeadlineExceeded))
148}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close