feat: implement HTMLDebug for PGCoord with v2 API (#10914)

Implements HTMLDebug for the PGCoordinator with the new v2 API and related DB tables.
This commit is contained in:
Spike Curtis 2023-11-28 22:37:20 +04:00 committed by GitHub
parent 18c4a98865
commit 52901e1219
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 428 additions and 115 deletions

View File

@ -82,6 +82,7 @@ helm/**/templates/*.yaml
# Testdata shouldn't be formatted.
scripts/apitypings/testdata/**/*.ts
enterprise/tailnet/testdata/*.golden.html
# Generated files shouldn't be formatted.
site/e2e/provisionerGenerated.ts

View File

@ -8,6 +8,7 @@ helm/**/templates/*.yaml
# Testdata shouldn't be formatted.
scripts/apitypings/testdata/**/*.ts
enterprise/tailnet/testdata/*.golden.html
# Generated files shouldn't be formatted.
site/e2e/provisionerGenerated.ts

View File

@ -595,7 +595,15 @@ coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS)
./scripts/apidocgen/generate.sh
pnpm run format:write:only ./docs/api ./docs/manifest.json ./coderd/apidoc/swagger.json
update-golden-files: cli/testdata/.gen-golden helm/coder/tests/testdata/.gen-golden helm/provisioner/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden coderd/.gen-golden provisioner/terraform/testdata/.gen-golden
update-golden-files: \
cli/testdata/.gen-golden \
helm/coder/tests/testdata/.gen-golden \
helm/provisioner/tests/testdata/.gen-golden \
scripts/ci-report/testdata/.gen-golden \
enterprise/cli/testdata/.gen-golden \
enterprise/tailnet/testdata/.gen-golden \
coderd/.gen-golden \
provisioner/terraform/testdata/.gen-golden
.PHONY: update-golden-files
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go)
@ -606,6 +614,10 @@ enterprise/cli/testdata/.gen-golden: $(wildcard enterprise/cli/testdata/*.golden
go test ./enterprise/cli -run="TestEnterpriseCommandHelp" -update
touch "$@"
enterprise/tailnet/testdata/.gen-golden: $(wildcard enterprise/tailnet/testdata/*.golden.html) $(GO_SRC_FILES) $(wildcard enterprise/tailnet/*_test.go)
go test ./enterprise/tailnet -run="TestDebugTemplate" -update
touch "$@"
helm/coder/tests/testdata/.gen-golden: $(wildcard helm/coder/tests/testdata/*.yaml) $(wildcard helm/coder/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/coder/tests/*_test.go)
go test ./helm/coder/tests -run=TestUpdateGoldenFiles -update
touch "$@"

View File

@ -0,0 +1,199 @@
package tailnet
import (
"context"
"database/sql"
"html/template"
"net/http"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
gProto "google.golang.org/protobuf/proto"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/tailnet/proto"
)
type HTMLDebug struct {
Coordinators []*HTMLCoordinator
Peers []*HTMLPeer
Tunnels []*HTMLTunnel
}
type HTMLPeer struct {
ID uuid.UUID
CoordinatorID uuid.UUID
LastWriteAge time.Duration
Node string
Status database.TailnetStatus
}
type HTMLCoordinator struct {
ID uuid.UUID
HeartbeatAge time.Duration
}
type HTMLTunnel struct {
CoordinatorID uuid.UUID
SrcID uuid.UUID
DstID uuid.UUID
LastWriteAge time.Duration
}
func (c *pgCoord) ServeHTTPDebug(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
debug, err := getDebug(ctx, c.store)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err = debugTempl.Execute(w, debug)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
}
}
func getDebug(ctx context.Context, store database.Store) (HTMLDebug, error) {
out := HTMLDebug{}
coords, err := store.GetAllTailnetCoordinators(ctx)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return HTMLDebug{}, xerrors.Errorf("failed to query coordinators: %w", err)
}
peers, err := store.GetAllTailnetPeers(ctx)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return HTMLDebug{}, xerrors.Errorf("failed to query peers: %w", err)
}
tunnels, err := store.GetAllTailnetTunnels(ctx)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return HTMLDebug{}, xerrors.Errorf("failed to query tunnels: %w", err)
}
now := time.Now() // call this once so all our ages are on the same timebase
for _, coord := range coords {
out.Coordinators = append(out.Coordinators, coordToHTML(coord, now))
}
for _, peer := range peers {
ph, err := peerToHTML(peer, now)
if err != nil {
return HTMLDebug{}, err
}
out.Peers = append(out.Peers, ph)
}
for _, tunnel := range tunnels {
out.Tunnels = append(out.Tunnels, tunnelToHTML(tunnel, now))
}
return out, nil
}
func coordToHTML(d database.TailnetCoordinator, now time.Time) *HTMLCoordinator {
return &HTMLCoordinator{
ID: d.ID,
HeartbeatAge: now.Sub(d.HeartbeatAt),
}
}
func peerToHTML(d database.TailnetPeer, now time.Time) (*HTMLPeer, error) {
node := &proto.Node{}
err := gProto.Unmarshal(d.Node, node)
if err != nil {
return nil, xerrors.Errorf("unmarshal node: %w", err)
}
return &HTMLPeer{
ID: d.ID,
CoordinatorID: d.CoordinatorID,
LastWriteAge: now.Sub(d.UpdatedAt),
Status: d.Status,
Node: node.String(),
}, nil
}
func tunnelToHTML(d database.TailnetTunnel, now time.Time) *HTMLTunnel {
return &HTMLTunnel{
CoordinatorID: d.CoordinatorID,
SrcID: d.SrcID,
DstID: d.DstID,
LastWriteAge: now.Sub(d.UpdatedAt),
}
}
var coordinatorDebugTmpl = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
th, td {
padding-top: 6px;
padding-bottom: 6px;
padding-left: 10px;
padding-right: 10px;
text-align: left;
}
tr {
border-bottom: 1px solid #ddd;
}
</style>
</head>
<body>
<h2 id=coordinators><a href=#coordinators>#</a> coordinators: total {{ len .Coordinators }}</h2>
<table>
<tr style="margin-top:4px">
<th>ID</th>
<th>Heartbeat Age</th>
</tr>
{{- range .Coordinators}}
<tr style="margin-top:4px">
<td>{{ .ID }}</td>
<td>{{ .HeartbeatAge }} ago</td>
</tr>
{{- end }}
</table>
<h2 id=peers> <a href=#peers>#</a> peers: total {{ len .Peers }} </h2>
<table>
<tr style="margin-top:4px">
<th>ID</th>
<th>CoordinatorID</th>
<th>Status</th>
<th>Last Write Age</th>
<th>Node</th>
</tr>
{{- range .Peers }}
<tr style="margin-top:4px">
<td>{{ .ID }}</td>
<td>{{ .CoordinatorID }}</td>
<td>{{ .Status }}</td>
<td>{{ .LastWriteAge }} ago</td>
<td style="white-space: pre;"><code>{{ .Node }}</code></td>
</tr>
{{- end }}
</table>
<h2 id=tunnels><a href=#tunnels>#</a> tunnels: total {{ len .Tunnels }}</h2>
<table>
<tr style="margin-top:4px">
<th>SrcID</th>
<th>DstID</th>
<th>CoordinatorID</th>
<th>Last Write Age</th>
</tr>
{{- range .Tunnels }}
<tr style="margin-top:4px">
<td>{{ .SrcID }}</td>
<td>{{ .DstID }}</td>
<td>{{ .CoordinatorID }}</td>
<td>{{ .LastWriteAge }} ago</td>
</tr>
{{- end }}
</table>
</body>
</html>
`
var debugTempl = template.Must(template.New("coordinator_debug").Parse(coordinatorDebugTmpl))

View File

@ -6,7 +6,6 @@ import (
"encoding/json"
"io"
"net"
"net/http"
"net/netip"
"strings"
"sync"
@ -19,7 +18,6 @@ import (
"github.com/cenkalti/backoff/v4"
"github.com/google/uuid"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
gProto "google.golang.org/protobuf/proto"
@ -28,7 +26,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/slice"
agpl "github.com/coder/coder/v2/tailnet"
)
@ -1296,29 +1293,6 @@ func (q *querier) setHealthy() {
q.healthy = true
}
func (q *querier) getAll(ctx context.Context) (map[uuid.UUID]database.TailnetAgent, map[uuid.UUID][]database.TailnetClient, error) {
agents, err := q.store.GetAllTailnetAgents(ctx)
if err != nil {
return nil, nil, xerrors.Errorf("get all tailnet agents: %w", err)
}
agentsMap := map[uuid.UUID]database.TailnetAgent{}
for _, agent := range agents {
agentsMap[agent.ID] = agent
}
clients, err := q.store.GetAllTailnetClients(ctx)
if err != nil {
return nil, nil, xerrors.Errorf("get all tailnet clients: %w", err)
}
clientsMap := map[uuid.UUID][]database.TailnetClient{}
for _, client := range clients {
for _, agentID := range client.AgentIds {
clientsMap[agentID] = append(clientsMap[agentID], client.TailnetClient)
}
}
return agentsMap, clientsMap, nil
}
func parseTunnelUpdate(msg string) ([]uuid.UUID, error) {
parts := strings.Split(msg, ",")
if len(parts) != 2 {
@ -1721,91 +1695,3 @@ func (h *heartbeats) cleanup() {
}
h.logger.Debug(h.ctx, "cleaned up old coordinators")
}
func (c *pgCoord) ServeHTTPDebug(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
debug, err := c.htmlDebug(ctx)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
}
agpl.CoordinatorHTTPDebug(debug)(w, r)
}
func (c *pgCoord) htmlDebug(ctx context.Context) (agpl.HTMLDebug, error) {
now := time.Now()
data := agpl.HTMLDebug{}
agents, clients, err := c.querier.getAll(ctx)
if err != nil {
return data, xerrors.Errorf("get all agents and clients: %w", err)
}
for _, agent := range agents {
htmlAgent := &agpl.HTMLAgent{
ID: agent.ID,
// Name: ??, TODO: get agent names
LastWriteAge: now.Sub(agent.UpdatedAt).Round(time.Second),
}
for _, conn := range clients[agent.ID] {
htmlAgent.Connections = append(htmlAgent.Connections, &agpl.HTMLClient{
ID: conn.ID,
Name: conn.ID.String(),
LastWriteAge: now.Sub(conn.UpdatedAt).Round(time.Second),
})
data.Nodes = append(data.Nodes, &agpl.HTMLNode{
ID: conn.ID,
Node: conn.Node,
})
}
slices.SortFunc(htmlAgent.Connections, func(a, b *agpl.HTMLClient) int {
return slice.Ascending(a.Name, b.Name)
})
data.Agents = append(data.Agents, htmlAgent)
data.Nodes = append(data.Nodes, &agpl.HTMLNode{
ID: agent.ID,
// Name: ??, TODO: get agent names
Node: agent.Node,
})
}
slices.SortFunc(data.Agents, func(a, b *agpl.HTMLAgent) int {
return slice.Ascending(a.Name, b.Name)
})
for agentID, conns := range clients {
if len(conns) == 0 {
continue
}
if _, ok := agents[agentID]; ok {
continue
}
agent := &agpl.HTMLAgent{
Name: "unknown",
ID: agentID,
}
for _, conn := range conns {
agent.Connections = append(agent.Connections, &agpl.HTMLClient{
Name: conn.ID.String(),
ID: conn.ID,
LastWriteAge: now.Sub(conn.UpdatedAt).Round(time.Second),
})
data.Nodes = append(data.Nodes, &agpl.HTMLNode{
ID: conn.ID,
Node: conn.Node,
})
}
slices.SortFunc(agent.Connections, func(a, b *agpl.HTMLClient) int {
return slice.Ascending(a.Name, b.Name)
})
data.MissingAgents = append(data.MissingAgents, agent)
}
slices.SortFunc(data.MissingAgents, func(a, b *agpl.HTMLAgent) int {
return slice.Ascending(a.Name, b.Name)
})
return data, nil
}

View File

@ -1,19 +1,35 @@
package tailnet
import (
"bytes"
"context"
"flag"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
gProto "google.golang.org/protobuf/proto"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/tailnet/proto"
"github.com/coder/coder/v2/testutil"
)
// UpdateGoldenFiles indicates golden files should be updated.
// To update the golden files:
// make update-golden-files
var UpdateGoldenFiles = flag.Bool("update", false, "update .golden files")
// TestHeartbeat_Cleanup is internal so that we can overwrite the cleanup period and not wait an hour for the timed
// cleanup.
func TestHeartbeat_Cleanup(t *testing.T) {
@ -50,3 +66,122 @@ func TestHeartbeat_Cleanup(t *testing.T) {
}
close(waitForCleanup)
}
func TestDebugTemplate(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("newlines screw up golden files on windows")
}
c1 := uuid.MustParse("01000000-1111-1111-1111-111111111111")
c2 := uuid.MustParse("02000000-1111-1111-1111-111111111111")
p1 := uuid.MustParse("01000000-2222-2222-2222-222222222222")
p2 := uuid.MustParse("02000000-2222-2222-2222-222222222222")
in := HTMLDebug{
Coordinators: []*HTMLCoordinator{
{
ID: c1,
HeartbeatAge: 2 * time.Second,
},
{
ID: c2,
HeartbeatAge: time.Second,
},
},
Peers: []*HTMLPeer{
{
ID: p1,
CoordinatorID: c1,
LastWriteAge: 5 * time.Second,
Status: database.TailnetStatusOk,
Node: `id:1 preferred_derp:999 endpoints:"192.168.0.49:4449"`,
},
{
ID: p2,
CoordinatorID: c2,
LastWriteAge: 7 * time.Second,
Status: database.TailnetStatusLost,
Node: `id:2 preferred_derp:999 endpoints:"192.168.0.33:4449"`,
},
},
Tunnels: []*HTMLTunnel{
{
CoordinatorID: c1,
SrcID: p1,
DstID: p2,
LastWriteAge: 3 * time.Second,
},
},
}
buf := new(bytes.Buffer)
err := debugTempl.Execute(buf, in)
require.NoError(t, err)
actual := buf.Bytes()
goldenPath := filepath.Join("testdata", "debug.golden.html")
if *UpdateGoldenFiles {
t.Logf("update golden file %s", goldenPath)
err := os.WriteFile(goldenPath, actual, 0o600)
require.NoError(t, err, "update golden file")
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "read golden file, run \"make update-golden-files\" and commit the changes")
require.Equal(
t, string(expected), string(actual),
"golden file mismatch: %s, run \"make update-golden-files\", verify and commit the changes",
goldenPath,
)
}
func TestGetDebug(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.Skip("test only with postgres")
}
store, _ := dbtestutil.NewDB(t)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
coordID := uuid.New()
_, err := store.UpsertTailnetCoordinator(ctx, coordID)
require.NoError(t, err)
peerID := uuid.New()
node := &proto.Node{PreferredDerp: 44}
nodeb, err := gProto.Marshal(node)
require.NoError(t, err)
_, err = store.UpsertTailnetPeer(ctx, database.UpsertTailnetPeerParams{
ID: peerID,
CoordinatorID: coordID,
Node: nodeb,
Status: database.TailnetStatusLost,
})
require.NoError(t, err)
dstID := uuid.New()
_, err = store.UpsertTailnetTunnel(ctx, database.UpsertTailnetTunnelParams{
CoordinatorID: coordID,
SrcID: peerID,
DstID: dstID,
})
require.NoError(t, err)
debug, err := getDebug(ctx, store)
require.NoError(t, err)
require.Len(t, debug.Coordinators, 1)
require.Len(t, debug.Peers, 1)
require.Len(t, debug.Tunnels, 1)
require.Equal(t, coordID, debug.Coordinators[0].ID)
require.Equal(t, peerID, debug.Peers[0].ID)
require.Equal(t, coordID, debug.Peers[0].CoordinatorID)
require.Equal(t, database.TailnetStatusLost, debug.Peers[0].Status)
require.Equal(t, node.String(), debug.Peers[0].Node)
require.Equal(t, coordID, debug.Tunnels[0].CoordinatorID)
require.Equal(t, peerID, debug.Tunnels[0].SrcID)
require.Equal(t, dstID, debug.Tunnels[0].DstID)
}

View File

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
th, td {
padding-top: 6px;
padding-bottom: 6px;
padding-left: 10px;
padding-right: 10px;
text-align: left;
}
tr {
border-bottom: 1px solid #ddd;
}
</style>
</head>
<body>
<h2 id=coordinators><a href=#coordinators>#</a> coordinators: total 2</h2>
<table>
<tr style="margin-top:4px">
<th>ID</th>
<th>Heartbeat Age</th>
</tr>
<tr style="margin-top:4px">
<td>01000000-1111-1111-1111-111111111111</td>
<td>2s ago</td>
</tr>
<tr style="margin-top:4px">
<td>02000000-1111-1111-1111-111111111111</td>
<td>1s ago</td>
</tr>
</table>
<h2 id=peers> <a href=#peers>#</a> peers: total 2 </h2>
<table>
<tr style="margin-top:4px">
<th>ID</th>
<th>CoordinatorID</th>
<th>Status</th>
<th>Last Write Age</th>
<th>Node</th>
</tr>
<tr style="margin-top:4px">
<td>01000000-2222-2222-2222-222222222222</td>
<td>01000000-1111-1111-1111-111111111111</td>
<td>ok</td>
<td>5s ago</td>
<td style="white-space: pre;"><code>id:1 preferred_derp:999 endpoints:&#34;192.168.0.49:4449&#34;</code></td>
</tr>
<tr style="margin-top:4px">
<td>02000000-2222-2222-2222-222222222222</td>
<td>02000000-1111-1111-1111-111111111111</td>
<td>lost</td>
<td>7s ago</td>
<td style="white-space: pre;"><code>id:2 preferred_derp:999 endpoints:&#34;192.168.0.33:4449&#34;</code></td>
</tr>
</table>
<h2 id=tunnels><a href=#tunnels>#</a> tunnels: total 1</h2>
<table>
<tr style="margin-top:4px">
<th>SrcID</th>
<th>DstID</th>
<th>CoordinatorID</th>
<th>Last Write Age</th>
</tr>
<tr style="margin-top:4px">
<td>01000000-2222-2222-2222-222222222222</td>
<td>02000000-2222-2222-2222-222222222222</td>
<td>01000000-1111-1111-1111-111111111111</td>
<td>3s ago</td>
</tr>
</table>
</body>
</html>

View File

@ -82,6 +82,7 @@ result
# Testdata shouldn't be formatted.
../scripts/apitypings/testdata/**/*.ts
../enterprise/tailnet/testdata/*.golden.html
# Generated files shouldn't be formatted.
e2e/provisionerGenerated.ts

View File

@ -82,6 +82,7 @@ result
# Testdata shouldn't be formatted.
../scripts/apitypings/testdata/**/*.ts
../enterprise/tailnet/testdata/*.golden.html
# Generated files shouldn't be formatted.
e2e/provisionerGenerated.ts