3c77895788
A host being PXE-discovered or installed is not in Kubernetes, so vlagent cannot collect its logs and a failed install leaves no record; the installer environment has no internal-CA trust or credentials for the HTTPS log ingest, and bootapi is already the plain-HTTP broker it can reach. - add POST /logs, token-guarded like POST /provisioned, relaying ndjson to vlinsert's jsonline endpoint keyed on serial+phase - stamp observed source IP and resolved NetBox device name into extra_fields - return 202 on a sink failure so logs never block an install - add BOOTAPI_VLINSERT_URL/_TIMEOUT and bootapi_log_relay metrics
489 lines
16 KiB
Go
489 lines
16 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.unkin.net/unkin/bootapi/internal/model"
|
|
"git.unkin.net/unkin/bootapi/internal/netbox"
|
|
"git.unkin.net/unkin/bootapi/internal/render"
|
|
"git.unkin.net/unkin/bootapi/templates"
|
|
)
|
|
|
|
// fakeNB is a canned netbox.API (reads + pxe_enabled write) for handler tests.
|
|
type fakeNB struct {
|
|
byMAC map[string]*model.Host
|
|
byName map[string]*model.Host
|
|
err error
|
|
writeErr error
|
|
writes []int // device IDs written via SetPXEEnabled
|
|
}
|
|
|
|
func (f *fakeNB) HostByMAC(_ context.Context, mac string) (*model.Host, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
// The real NetBox client normalizes MAC case/separators before matching;
|
|
// mirror that here so case-insensitive lookups behave like production.
|
|
if h, ok := f.byMAC[strings.ToLower(mac)]; ok {
|
|
return h, nil
|
|
}
|
|
return nil, netbox.ErrNotFound
|
|
}
|
|
func (f *fakeNB) HostByName(_ context.Context, name string) (*model.Host, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
if h, ok := f.byName[name]; ok {
|
|
return h, nil
|
|
}
|
|
return nil, netbox.ErrNotFound
|
|
}
|
|
func (f *fakeNB) SetPXEEnabled(_ context.Context, deviceID int, _ bool) error {
|
|
if f.writeErr != nil {
|
|
return f.writeErr
|
|
}
|
|
f.writes = append(f.writes, deviceID)
|
|
return nil
|
|
}
|
|
|
|
func testHost() *model.Host {
|
|
return &model.Host{
|
|
DeviceID: 12,
|
|
Hostname: "web01", Domain: "syd1.au.unkin.net", FQDN: "web01.syd1.au.unkin.net",
|
|
Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64",
|
|
PrimaryIP: "10.0.1.20",
|
|
Interfaces: []model.Interface{
|
|
{Name: "eth0", MAC: "aa:bb:cc:00:11:22", IP: "10.0.1.20", PrefixLen: 24, Netmask: "255.255.255.0", Gateway: "10.0.1.254", Primary: true},
|
|
},
|
|
}
|
|
}
|
|
|
|
func newTestServer(t *testing.T, nb netbox.API, fallback string) *Server {
|
|
t.Helper()
|
|
return newTestServerToken(t, nb, fallback, "")
|
|
}
|
|
|
|
func newTestServerToken(t *testing.T, nb netbox.API, fallback, provToken string) *Server {
|
|
t.Helper()
|
|
return newTestServerLogs(t, nb, fallback, provToken, "")
|
|
}
|
|
|
|
func newTestServerLogs(t *testing.T, nb netbox.API, fallback, provToken, vlURL string) *Server {
|
|
t.Helper()
|
|
set, err := render.BuildSet(templates.FS, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
eng := render.NewEngine(render.RenderConfig{
|
|
PuppetServer: "puppet.k8s.syd1.au.unkin.net", PuppetCAServer: "puppetca.k8s.syd1.au.unkin.net",
|
|
BaseURL: "http://bootapi.example.net", CallbackBaseURL: "http://bootapi.example.net",
|
|
ArtifactBase: "https://af.example/api/v1/remote", ProvisionToken: provToken,
|
|
DefaultDomain: "main.unkin.net", DefaultTemplate: "almalinux9",
|
|
RootPasswordHash: "$6$abc$def",
|
|
}, set)
|
|
return New(Options{
|
|
NetBox: nb, Engine: eng, UnknownMACFallback: fallback,
|
|
ProvisionToken: provToken, VLInsertURL: vlURL, VLInsertTimeout: 2 * time.Second,
|
|
})
|
|
}
|
|
|
|
func do(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
return rec
|
|
}
|
|
|
|
func post(t *testing.T, h http.Handler, path, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, path, nil)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestIPXEKnownMAC(t *testing.T) {
|
|
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
|
h := newTestServer(t, res, "local").Router()
|
|
|
|
rec := do(t, h, "/ipxe/aa:bb:cc:00:11:22")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "inst.ks=http://bootapi.example.net/ks/web01") {
|
|
t.Errorf("ipxe body missing inst.ks:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestIPXEUnknownMACServesFallback200(t *testing.T) {
|
|
h := newTestServer(t, &fakeNB{}, "local").Router()
|
|
rec := do(t, h, "/ipxe/de:ad:be:ef:00:00")
|
|
// Unknown MAC must NOT 404 — iPXE needs a valid script. Safe local-boot.
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 with fallback", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "sanboot") {
|
|
t.Errorf("expected local-boot fallback, got:\n%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestIPXEUnknownMACShellFallback(t *testing.T) {
|
|
h := newTestServer(t, &fakeNB{}, "shell").Router()
|
|
rec := do(t, h, "/ipxe/de:ad:be:ef:00:00")
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "shell") {
|
|
t.Fatalf("shell fallback not served: %d\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestIPXEQueryAlias(t *testing.T) {
|
|
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
|
h := newTestServer(t, res, "local").Router()
|
|
rec := do(t, h, "/boot/ipxe?mac=AA:BB:CC:00:11:22")
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "inst.ks=") {
|
|
t.Fatalf("query-style ipxe failed: %d\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestKickstartByMAC(t *testing.T) {
|
|
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
|
h := newTestServer(t, res, "local").Router()
|
|
rec := do(t, h, "/ks/aa:bb:cc:00:11:22")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
|
t.Errorf("content-type = %q", ct)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "rootpw --iscrypted") {
|
|
t.Errorf("kickstart body missing rootpw:\n%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestKickstartByHostname(t *testing.T) {
|
|
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
|
|
h := newTestServer(t, res, "local").Router()
|
|
rec := do(t, h, "/ks/web01.cfg") // .cfg suffix must be stripped
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestKickstartUnknownIs404(t *testing.T) {
|
|
h := newTestServer(t, &fakeNB{}, "local").Router()
|
|
rec := do(t, h, "/ks/nosuchhost")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404 (kickstart must fail loudly)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHealthAndReady(t *testing.T) {
|
|
h := newTestServer(t, &fakeNB{}, "local").Router()
|
|
if rec := do(t, h, "/healthz"); rec.Code != http.StatusOK {
|
|
t.Errorf("healthz = %d", rec.Code)
|
|
}
|
|
if rec := do(t, h, "/readyz"); rec.Code != http.StatusOK {
|
|
t.Errorf("readyz = %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestMetricsEndpoint(t *testing.T) {
|
|
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
|
srv := newTestServer(t, res, "local")
|
|
h := srv.Router()
|
|
|
|
do(t, h, "/ipxe/aa:bb:cc:00:11:22") // ok render + netbox ok
|
|
do(t, h, "/ipxe/de:ad:be:ef:00:00") // notfound + fallback
|
|
do(t, h, "/ks/aa:bb:cc:00:11:22") // kickstart render
|
|
|
|
rec := do(t, h, "/metrics")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("metrics status = %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`bootapi_render_total{kind="ipxe",result="ok"} 1`,
|
|
`bootapi_render_total{kind="kickstart",result="ok"} 1`,
|
|
`bootapi_netbox_lookups_total{field="mac",result="notfound"} 1`,
|
|
"bootapi_http_requests_total",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("metrics missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIPXEGatedWhenPXEDisabled(t *testing.T) {
|
|
disabled := false
|
|
host := testHost()
|
|
host.PXEEnabled = &disabled // pxe_enabled=false: known host must NOT reinstall
|
|
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": host}}
|
|
srv := newTestServer(t, res, "local")
|
|
h := srv.Router()
|
|
|
|
rec := do(t, h, "/ipxe/aa:bb:cc:00:11:22")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "sanboot") || strings.Contains(body, "inst.ks=") {
|
|
t.Errorf("gated host should get local-boot fallback, not an installer:\n%s", body)
|
|
}
|
|
if !strings.Contains(do(t, h, "/metrics").Body.String(), "bootapi_ipxe_gated_total 1") {
|
|
t.Error("gate metric not incremented")
|
|
}
|
|
}
|
|
|
|
func TestProvisionedCallbackOK(t *testing.T) {
|
|
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
|
|
h := newTestServerToken(t, res, "local", "prov-secret").Router()
|
|
|
|
rec := post(t, h, "/provisioned/web01", "prov-secret")
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(res.writes) != 1 || res.writes[0] != 12 {
|
|
t.Errorf("expected SetPXEEnabled on device 12, got writes=%v", res.writes)
|
|
}
|
|
}
|
|
|
|
func TestProvisionedCallbackAuth(t *testing.T) {
|
|
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
|
|
h := newTestServerToken(t, res, "local", "prov-secret").Router()
|
|
|
|
if rec := post(t, h, "/provisioned/web01", "wrong"); rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("wrong token: status = %d, want 401", rec.Code)
|
|
}
|
|
if rec := post(t, h, "/provisioned/web01", ""); rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("no token: status = %d, want 401", rec.Code)
|
|
}
|
|
if len(res.writes) != 0 {
|
|
t.Errorf("unauthorized calls must not write NetBox, got %v", res.writes)
|
|
}
|
|
}
|
|
|
|
func TestProvisionedCallbackDisabled(t *testing.T) {
|
|
// No provision token configured -> endpoint fails closed.
|
|
res := &fakeNB{byName: map[string]*model.Host{"web01": testHost()}}
|
|
h := newTestServer(t, res, "local").Router()
|
|
if rec := post(t, h, "/provisioned/web01", "anything"); rec.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503 when no token configured", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestProvisionedCallbackUnknownHost(t *testing.T) {
|
|
h := newTestServerToken(t, &fakeNB{}, "local", "prov-secret").Router()
|
|
if rec := post(t, h, "/provisioned/nosuch", "prov-secret"); rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLooksLikeMAC(t *testing.T) {
|
|
yes := []string{"aa:bb:cc:00:11:22", "aa-bb-cc-00-11-22", "aabbcc001122", "aabb.cc00.1122"}
|
|
no := []string{"web01", "web01.example.net", "aa:bb:cc", "zz:bb:cc:00:11:22"}
|
|
for _, s := range yes {
|
|
if !looksLikeMAC(s) {
|
|
t.Errorf("looksLikeMAC(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range no {
|
|
if looksLikeMAC(s) {
|
|
t.Errorf("looksLikeMAC(%q) = true, want false", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// vlStub is a stand-in vlinsert that records what bootapi forwarded.
|
|
type vlStub struct {
|
|
srv *httptest.Server
|
|
hits int
|
|
path string
|
|
query url.Values
|
|
body string
|
|
ctype string
|
|
status int
|
|
}
|
|
|
|
func newVLStub(t *testing.T, status int) *vlStub {
|
|
t.Helper()
|
|
v := &vlStub{status: status}
|
|
v.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Errorf("vlinsert stub read body: %v", err)
|
|
}
|
|
v.hits++
|
|
v.path, v.query, v.body, v.ctype = r.URL.Path, r.URL.Query(), string(b), r.Header.Get("Content-Type")
|
|
w.WriteHeader(v.status)
|
|
}))
|
|
t.Cleanup(v.srv.Close)
|
|
return v
|
|
}
|
|
|
|
func postLogs(t *testing.T, h http.Handler, path, token, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/x-ndjson")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestLogRelayForwardsToVLInsert(t *testing.T) {
|
|
vl := newVLStub(t, http.StatusNoContent)
|
|
res := &fakeNB{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
|
|
h := newTestServerLogs(t, res, "local", "prov-secret", vl.srv.URL).Router()
|
|
|
|
batch := `{"time":"2026-10-03T00:00:00Z","serial":"SN1","phase":"discovery","msg":"hello"}`
|
|
rec := postLogs(t, h, "/logs?mac=aa:bb:cc:00:11:22", "prov-secret", batch+"\n")
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("status = %d, want 202\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
if vl.hits != 1 {
|
|
t.Fatalf("vlinsert hits = %d, want 1", vl.hits)
|
|
}
|
|
if vl.path != "/insert/jsonline" {
|
|
t.Errorf("path = %q", vl.path)
|
|
}
|
|
for k, want := range map[string]string{
|
|
"_stream_fields": "serial,phase",
|
|
"_msg_field": "msg",
|
|
"_time_field": "time",
|
|
} {
|
|
if got := vl.query.Get(k); got != want {
|
|
t.Errorf("query %s = %q, want %q", k, got, want)
|
|
}
|
|
}
|
|
// extra_fields carries only what bootapi observed, not client claims.
|
|
if got := vl.query.Get("extra_fields"); got != "src_ip=192.0.2.1,device=web01" {
|
|
t.Errorf("extra_fields = %q", got)
|
|
}
|
|
if strings.TrimSpace(vl.body) != batch {
|
|
t.Errorf("forwarded body = %q", vl.body)
|
|
}
|
|
if vl.ctype != "application/x-ndjson" {
|
|
t.Errorf("forwarded content-type = %q", vl.ctype)
|
|
}
|
|
}
|
|
|
|
func TestLogRelayUnresolvedMACStillForwards(t *testing.T) {
|
|
vl := newVLStub(t, http.StatusNoContent)
|
|
h := newTestServerLogs(t, &fakeNB{}, "local", "prov-secret", vl.srv.URL).Router()
|
|
|
|
rec := postLogs(t, h, "/logs?mac=de:ad:be:ef:00:00", "prov-secret", `{"msg":"x"}`+"\n")
|
|
if rec.Code != http.StatusAccepted || vl.hits != 1 {
|
|
t.Fatalf("status = %d hits = %d, want 202/1", rec.Code, vl.hits)
|
|
}
|
|
if got := vl.query.Get("extra_fields"); got != "src_ip=192.0.2.1" {
|
|
t.Errorf("unresolved MAC must omit device: extra_fields = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLogRelayAuth(t *testing.T) {
|
|
vl := newVLStub(t, http.StatusNoContent)
|
|
h := newTestServerLogs(t, &fakeNB{}, "local", "prov-secret", vl.srv.URL).Router()
|
|
|
|
if rec := postLogs(t, h, "/logs", "wrong", `{"msg":"x"}`); rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("wrong token: status = %d, want 401", rec.Code)
|
|
}
|
|
if rec := postLogs(t, h, "/logs", "", `{"msg":"x"}`); rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("no token: status = %d, want 401", rec.Code)
|
|
}
|
|
if vl.hits != 0 {
|
|
t.Errorf("unauthorized calls must not forward, hits = %d", vl.hits)
|
|
}
|
|
}
|
|
|
|
func TestLogRelayDisabledWithoutProvisionToken(t *testing.T) {
|
|
vl := newVLStub(t, http.StatusNoContent)
|
|
h := newTestServerLogs(t, &fakeNB{}, "local", "", vl.srv.URL).Router()
|
|
if rec := postLogs(t, h, "/logs", "anything", `{"msg":"x"}`); rec.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503 when no token configured", rec.Code)
|
|
}
|
|
if vl.hits != 0 {
|
|
t.Errorf("disabled relay must not forward, hits = %d", vl.hits)
|
|
}
|
|
}
|
|
|
|
func TestLogRelayDisabledWithoutVLInsertURL(t *testing.T) {
|
|
h := newTestServerLogs(t, &fakeNB{}, "local", "prov-secret", "").Router()
|
|
if rec := postLogs(t, h, "/logs", "prov-secret", `{"msg":"x"}`); rec.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503 when BOOTAPI_VLINSERT_URL is empty", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLogRelayEmptyBody(t *testing.T) {
|
|
vl := newVLStub(t, http.StatusNoContent)
|
|
h := newTestServerLogs(t, &fakeNB{}, "local", "prov-secret", vl.srv.URL).Router()
|
|
if rec := postLogs(t, h, "/logs", "prov-secret", "\n \n"); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400 for an empty batch", rec.Code)
|
|
}
|
|
if vl.hits != 0 {
|
|
t.Errorf("empty batch must not forward, hits = %d", vl.hits)
|
|
}
|
|
}
|
|
|
|
func TestLogRelayOversizedBody(t *testing.T) {
|
|
vl := newVLStub(t, http.StatusNoContent)
|
|
h := newTestServerLogs(t, &fakeNB{}, "local", "prov-secret", vl.srv.URL).Router()
|
|
big := strings.Repeat("x", maxLogBody+1)
|
|
if rec := postLogs(t, h, "/logs", "prov-secret", big); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400 for an oversized batch", rec.Code)
|
|
}
|
|
if vl.hits != 0 {
|
|
t.Errorf("oversized batch must not forward, hits = %d", vl.hits)
|
|
}
|
|
}
|
|
|
|
func TestLogRelayVLInsertFailureStillAccepts(t *testing.T) {
|
|
vl := newVLStub(t, http.StatusInternalServerError)
|
|
srv := newTestServerLogs(t, &fakeNB{}, "local", "prov-secret", vl.srv.URL)
|
|
h := srv.Router()
|
|
|
|
// A dead log sink must never block an install.
|
|
if rec := postLogs(t, h, "/logs", "prov-secret", `{"msg":"x"}`+"\n"); rec.Code != http.StatusAccepted {
|
|
t.Fatalf("status = %d, want 202 even when vlinsert fails", rec.Code)
|
|
}
|
|
body := do(t, h, "/metrics").Body.String()
|
|
if !strings.Contains(body, `bootapi_log_relay_total{result="error"} 1`) {
|
|
t.Error("error result not counted")
|
|
}
|
|
if strings.Contains(body, "bootapi_log_relay_lines_total 1") {
|
|
t.Error("dropped lines must not be counted as relayed")
|
|
}
|
|
}
|
|
|
|
func TestLogRelayLineCountMetric(t *testing.T) {
|
|
vl := newVLStub(t, http.StatusNoContent)
|
|
srv := newTestServerLogs(t, &fakeNB{}, "local", "prov-secret", vl.srv.URL)
|
|
h := srv.Router()
|
|
|
|
batch := `{"msg":"a"}` + "\n" + `{"msg":"b"}` + "\n" + `{"msg":"c"}` + "\n"
|
|
if rec := postLogs(t, h, "/logs", "prov-secret", batch); rec.Code != http.StatusAccepted {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
body := do(t, h, "/metrics").Body.String()
|
|
for _, want := range []string{
|
|
`bootapi_log_relay_total{result="ok"} 1`,
|
|
"bootapi_log_relay_lines_total 3",
|
|
`bootapi_http_requests_total{endpoint="logs",status="2xx"} 1`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("metrics missing %q", want)
|
|
}
|
|
}
|
|
}
|