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}