Skip to content

Commit c71cdf8

Browse files
Merge branch 'master' into ci/golangci-lint
2 parents 015800a + d14c2af commit c71cdf8

5 files changed

Lines changed: 439 additions & 0 deletions

File tree

.github/workflows/oasdiff.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: oasdiff (OpenAPI breaking-change check)
2+
3+
on:
4+
pull_request:
5+
branches: [master]
6+
paths:
7+
- 'internal/handlers/openapi.go'
8+
- 'go.mod'
9+
- 'go.sum'
10+
11+
permissions:
12+
contents: read
13+
pull-requests: write
14+
15+
jobs:
16+
diff:
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 10
19+
steps:
20+
- name: Checkout PR
21+
uses: actions/checkout@v4
22+
with:
23+
path: api
24+
- uses: actions/checkout@v4
25+
with:
26+
repository: InstaNode-dev/common
27+
path: common
28+
continue-on-error: true
29+
- uses: actions/checkout@v4
30+
with:
31+
repository: InstaNode-dev/proto
32+
path: proto
33+
continue-on-error: true
34+
- uses: actions/setup-go@v5
35+
with:
36+
go-version-file: api/go.mod
37+
- name: Build openapi.json from PR
38+
working-directory: api
39+
run: |
40+
go run ./cmd/openapi > /tmp/pr-openapi.json 2>/dev/null || \
41+
(go build ./... && ./api --emit-openapi > /tmp/pr-openapi.json 2>/dev/null) || \
42+
echo "WARNING: no openapi-emit binary found; fetching live spec"
43+
- name: Fetch live prod openapi for comparison
44+
run: |
45+
curl -sSfL https://api.instanode.dev/openapi.json -o /tmp/master-openapi.json || \
46+
echo '{"openapi":"3.1.0","paths":{}}' > /tmp/master-openapi.json
47+
- uses: oasdiff/oasdiff-action/breaking@main
48+
with:
49+
base: /tmp/master-openapi.json
50+
revision: /tmp/pr-openapi.json
51+
fail-on-diff: false # warn-only initially; flip to true once known-good baseline exists
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package handlers_test
2+
3+
// logs_resourcelogs_twinlogs_test.go — covers the error/edge arms of
4+
// LogsHandler.ResourceLogs (logs.go) that logs_coverage_test.go leaves open:
5+
//
6+
// logs.go:157-158 — lookup_failed: GetResourceByToken returns a non-NotFound
7+
// error (driven with a closed DB).
8+
// logs.go:194-196 — tail clamp: ?tail=0 (n<1) clamps up to 1.
9+
// logs.go:206-211 — pods_unavailable: the pod List call returns an error
10+
// (driven with a PrependReactor on the fake clientset).
11+
//
12+
// The clientset is the in-memory k8s fake (SetClientset seam), so these run
13+
// under CI's postgres-only matrix without a live cluster.
14+
15+
import (
16+
"errors"
17+
"net/http"
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
corev1 "k8s.io/api/core/v1"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/apimachinery/pkg/runtime"
25+
k8sfake "k8s.io/client-go/kubernetes/fake"
26+
k8stesting "k8s.io/client-go/testing"
27+
28+
"instant.dev/internal/handlers"
29+
"instant.dev/internal/testhelpers"
30+
)
31+
32+
// TestLogs_LookupFailed_503 drives logs.go:157-158: a DB error (not a
33+
// not-found) on GetResourceByToken returns 503 lookup_failed. We build the
34+
// handler against a CLOSED *sql.DB so the query fails with a driver error that
35+
// is NOT *models.ErrResourceNotFound.
36+
func TestLogs_LookupFailed_503(t *testing.T) {
37+
db, _ := testhelpers.SetupTestDB(t)
38+
h := handlers.NewLogsHandler(db)
39+
h.SetClientset(k8sfake.NewSimpleClientset())
40+
// Close the DB now so GetResourceByToken's query returns a driver error
41+
// (sql.ErrConnDone) — NOT a *models.ErrResourceNotFound — driving the
42+
// lookup_failed 503 arm rather than the not_found 404 arm.
43+
require.NoError(t, db.Close())
44+
45+
app := logsTestApp(t, db, h)
46+
// A syntactically valid UUID so we pass the parse gate and reach the lookup.
47+
resp := logsGet(t, app, "11111111-1111-1111-1111-111111111111", "")
48+
defer resp.Body.Close()
49+
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
50+
}
51+
52+
// TestLogs_TailClampLow_StreamsSSE drives logs.go:194-196: ?tail=0 (n<1) clamps
53+
// up to 1 and the happy path still streams. Needs a pod in the fake clientset.
54+
func TestLogs_TailClampLow_StreamsSSE(t *testing.T) {
55+
db, clean := testhelpers.SetupTestDB(t)
56+
defer clean()
57+
58+
const ns = "ns-clamp-low"
59+
cs := k8sfake.NewSimpleClientset(&corev1.Pod{
60+
ObjectMeta: metav1.ObjectMeta{
61+
Name: "postgres-0",
62+
Namespace: ns,
63+
Labels: map[string]string{"app": "postgres"},
64+
},
65+
})
66+
h := handlers.NewLogsHandler(db)
67+
h.SetClientset(cs)
68+
app := logsTestApp(t, db, h)
69+
70+
token := seedLogsResource(t, db, "postgres", "growth", "active", ns)
71+
resp := logsGet(t, app, token, "tail=0") // n<1 → clamp to 1
72+
defer resp.Body.Close()
73+
require.Equal(t, http.StatusOK, resp.StatusCode)
74+
assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
75+
}
76+
77+
// TestLogs_ListPodsError_503 drives logs.go:206-211: the pod List call errors.
78+
// A PrependReactor on the fake clientset makes List("pods") return an error so
79+
// the pods_unavailable arm runs (distinct from the empty-list pod_not_found arm
80+
// already covered).
81+
func TestLogs_ListPodsError_503(t *testing.T) {
82+
db, clean := testhelpers.SetupTestDB(t)
83+
defer clean()
84+
85+
cs := k8sfake.NewSimpleClientset()
86+
cs.PrependReactor("list", "pods",
87+
func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) {
88+
return true, nil, errors.New("apiserver unreachable")
89+
})
90+
h := handlers.NewLogsHandler(db)
91+
h.SetClientset(cs)
92+
app := logsTestApp(t, db, h)
93+
94+
token := seedLogsResource(t, db, "postgres", "growth", "active", "ns-list-err")
95+
resp := logsGet(t, app, token, "")
96+
defer resp.Body.Close()
97+
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
98+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package handlers_test
2+
3+
// logs_streamerr_twinlogs_test.go — covers the LAST uncovered arm of
4+
// LogsHandler.ResourceLogs (logs.go:230-236): req.Stream(streamCtx) returns an
5+
// error, so the handler logs stream_failed, cancels the background context, and
6+
// returns 503 stream_failed.
7+
//
8+
// The vanilla k8s fake clientset's GetLogs always returns a request whose
9+
// Stream succeeds with a canned "fake logs" body, so the error arm is
10+
// unreachable through it. We wrap the fake in a thin kubernetes.Interface that
11+
// delegates everything (so pod LIST still succeeds and we reach the GetLogs
12+
// step) EXCEPT pod GetLogs, which we override to return a request backed by a
13+
// rest/fake.RESTClient whose Err is set — making Stream(ctx) fail
14+
// deterministically.
15+
16+
import (
17+
"net/http"
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
corev1 "k8s.io/api/core/v1"
22+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
23+
"k8s.io/apimachinery/pkg/runtime/schema"
24+
"k8s.io/client-go/kubernetes"
25+
k8sfake "k8s.io/client-go/kubernetes/fake"
26+
"k8s.io/client-go/kubernetes/scheme"
27+
typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
28+
restclient "k8s.io/client-go/rest"
29+
restfake "k8s.io/client-go/rest/fake"
30+
31+
"instant.dev/internal/handlers"
32+
"instant.dev/internal/testhelpers"
33+
34+
"errors"
35+
)
36+
37+
// streamErrClientset wraps a real fake clientset; only pod GetLogs is altered so
38+
// its returned request fails on Stream.
39+
type streamErrClientset struct {
40+
kubernetes.Interface
41+
}
42+
43+
func (c *streamErrClientset) CoreV1() typedcorev1.CoreV1Interface {
44+
return &streamErrCoreV1{c.Interface.CoreV1()}
45+
}
46+
47+
type streamErrCoreV1 struct {
48+
typedcorev1.CoreV1Interface
49+
}
50+
51+
func (c *streamErrCoreV1) Pods(namespace string) typedcorev1.PodInterface {
52+
return &streamErrPods{c.CoreV1Interface.Pods(namespace)}
53+
}
54+
55+
type streamErrPods struct {
56+
typedcorev1.PodInterface
57+
}
58+
59+
// GetLogs returns a request whose Stream(ctx) errors — backed by a
60+
// rest/fake.RESTClient with Err set.
61+
func (p *streamErrPods) GetLogs(name string, opts *corev1.PodLogOptions) *restclient.Request {
62+
rc := &restfake.RESTClient{
63+
NegotiatedSerializer: scheme.Codecs.WithoutConversion(),
64+
GroupVersion: schema.GroupVersion{Version: "v1"},
65+
Err: errors.New("log stream upstream unavailable"),
66+
}
67+
return rc.Request()
68+
}
69+
70+
func TestLogs_StreamFailed_503(t *testing.T) {
71+
db, clean := testhelpers.SetupTestDB(t)
72+
defer clean()
73+
74+
const ns = "ns-stream-err"
75+
// A matching pod so the LIST step succeeds and we reach GetLogs/Stream.
76+
base := k8sfake.NewSimpleClientset(&corev1.Pod{
77+
ObjectMeta: metav1.ObjectMeta{
78+
Name: "postgres-0",
79+
Namespace: ns,
80+
Labels: map[string]string{"app": "postgres"},
81+
},
82+
})
83+
cs := &streamErrClientset{Interface: base}
84+
85+
h := handlers.NewLogsHandler(db)
86+
h.SetClientset(cs)
87+
app := logsTestApp(t, db, h)
88+
89+
token := seedLogsResource(t, db, "postgres", "growth", "active", ns)
90+
resp := logsGet(t, app, token, "")
91+
defer resp.Body.Close()
92+
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
93+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package handlers
2+
3+
// sse_logs_writeerr_twinlogs_test.go — covers the two write-error early-return
4+
// arms of streamLogsSSE (sse_logs.go) that the existing sse_logs_test.go leaves
5+
// open because its failingWriter only ever surfaces the error at the *Flush*
6+
// call (line 64), never at the WriteString call itself:
7+
//
8+
// sse_logs.go:61-63 — WriteString of a data line returns an error → return.
9+
// sse_logs.go:72-74 — WriteString of the end marker returns an error → return.
10+
//
11+
// A bufio.Writer's WriteString only returns an error when an internal flush
12+
// (forced when its buffer fills) hits the underlying writer's error. The
13+
// existing tests use the default 4 KiB buffer, so the small SSE lines never
14+
// force a mid-WriteString flush — the error always lands on the explicit
15+
// w.Flush() instead. Wrapping an immediately-failing writer in a size-1 bufio
16+
// buffer forces the flush to happen *inside* WriteString, surfacing the error
17+
// at lines 61 and 72.
18+
19+
import (
20+
"bufio"
21+
"strings"
22+
"testing"
23+
)
24+
25+
// alwaysFailWriter fails on the very first Write — modelling a fasthttp client
26+
// that disconnected before any byte landed.
27+
type alwaysFailWriter struct{ writes int }
28+
29+
func (a *alwaysFailWriter) Write(p []byte) (int, error) {
30+
a.writes++
31+
return 0, errWriteClosed
32+
}
33+
34+
// errWriteClosed is a sentinel write error (kept as a package-level var so the
35+
// closure above stays allocation-free and the intent is named).
36+
var errWriteClosed = &writeClosedError{}
37+
38+
type writeClosedError struct{}
39+
40+
func (*writeClosedError) Error() string { return "writer closed" }
41+
42+
// TestStreamLogsSSE_DataWriteStringError_BreaksPump drives sse_logs.go:61-63:
43+
// the WriteString of a data line returns an error (not just the later Flush),
44+
// so the pump returns immediately and the deferred Close + cancel still run.
45+
func TestStreamLogsSSE_DataWriteStringError_BreaksPump(t *testing.T) {
46+
stream := &trackedStream{Reader: strings.NewReader("a line that exceeds one byte\nsecond line\n")}
47+
// size-1 buffer → the first WriteString forces an internal flush mid-write,
48+
// surfacing the underlying writer error from WriteString itself (line 61),
49+
// not from the explicit Flush (line 64).
50+
fw := &alwaysFailWriter{}
51+
w := bufio.NewWriterSize(fw, 1)
52+
53+
cancelled := false
54+
streamLogsSSE(w, stream, func() { cancelled = true })
55+
56+
if stream.closes != 1 {
57+
t.Errorf("stream Close called %d times after WriteString error, want 1", stream.closes)
58+
}
59+
if !cancelled {
60+
t.Error("cancel not invoked after data-line WriteString error")
61+
}
62+
}
63+
64+
// TestStreamLogsSSE_EndMarkerWriteStringError drives sse_logs.go:72-74: an empty
65+
// stream writes no data lines, then the end-marker WriteString hits the failing
66+
// underlying writer (via the size-1 buffer flush) and returns — exercising the
67+
// end-marker write-error branch. Teardown (Close + cancel) still runs via defer.
68+
func TestStreamLogsSSE_EndMarkerWriteStringError(t *testing.T) {
69+
stream := &trackedStream{Reader: strings.NewReader("")} // no data lines
70+
fw := &alwaysFailWriter{}
71+
w := bufio.NewWriterSize(fw, 1)
72+
73+
cancelled := false
74+
streamLogsSSE(w, stream, func() { cancelled = true })
75+
76+
if fw.writes == 0 {
77+
t.Error("end-marker WriteString did not reach the underlying writer")
78+
}
79+
if stream.closes != 1 {
80+
t.Errorf("stream Close called %d times after end-marker write error, want 1", stream.closes)
81+
}
82+
if !cancelled {
83+
t.Error("cancel not invoked after end-marker WriteString error")
84+
}
85+
}

0 commit comments

Comments
 (0)