diff --git a/.prettierignore b/.prettierignore index 011d66b709..37cbd3fef3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/.prettierignore.include b/.prettierignore.include index 3a42bc75ec..fd7f94f13d 100644 --- a/.prettierignore.include +++ b/.prettierignore.include @@ -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 diff --git a/Makefile b/Makefile index 72e44308c6..1240bb57b1 100644 --- a/Makefile +++ b/Makefile @@ -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 "$@" diff --git a/enterprise/tailnet/htmldebug.go b/enterprise/tailnet/htmldebug.go new file mode 100644 index 0000000000..282c1bc9e5 --- /dev/null +++ b/enterprise/tailnet/htmldebug.go @@ -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 = ` + + + + + + + +

# coordinators: total {{ len .Coordinators }}

+ + + + + + {{- range .Coordinators}} + + + + + {{- end }} +
IDHeartbeat Age
{{ .ID }}{{ .HeartbeatAge }} ago
+ +

# peers: total {{ len .Peers }}

+ + + + + + + + + {{- range .Peers }} + + + + + + + + {{- end }} +
IDCoordinatorIDStatusLast Write AgeNode
{{ .ID }}{{ .CoordinatorID }}{{ .Status }}{{ .LastWriteAge }} ago{{ .Node }}
+ +

# tunnels: total {{ len .Tunnels }}

+ + + + + + + + {{- range .Tunnels }} + + + + + + + {{- end }} +
SrcIDDstIDCoordinatorIDLast Write Age
{{ .SrcID }}{{ .DstID }}{{ .CoordinatorID }}{{ .LastWriteAge }} ago
+ + +` + +var debugTempl = template.Must(template.New("coordinator_debug").Parse(coordinatorDebugTmpl)) diff --git a/enterprise/tailnet/pgcoord.go b/enterprise/tailnet/pgcoord.go index 3803b8cb20..a999e5586b 100644 --- a/enterprise/tailnet/pgcoord.go +++ b/enterprise/tailnet/pgcoord.go @@ -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 -} diff --git a/enterprise/tailnet/pgcoord_internal_test.go b/enterprise/tailnet/pgcoord_internal_test.go index 95481e6af3..a580a5fedd 100644 --- a/enterprise/tailnet/pgcoord_internal_test.go +++ b/enterprise/tailnet/pgcoord_internal_test.go @@ -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) +} diff --git a/enterprise/tailnet/testdata/debug.golden.html b/enterprise/tailnet/testdata/debug.golden.html new file mode 100644 index 0000000000..8f6648c620 --- /dev/null +++ b/enterprise/tailnet/testdata/debug.golden.html @@ -0,0 +1,77 @@ + + + + + + + + +

# coordinators: total 2

+ + + + + + + + + + + + + +
IDHeartbeat Age
01000000-1111-1111-1111-1111111111112s ago
02000000-1111-1111-1111-1111111111111s ago
+ +

# peers: total 2

+ + + + + + + + + + + + + + + + + + + + + + +
IDCoordinatorIDStatusLast Write AgeNode
01000000-2222-2222-2222-22222222222201000000-1111-1111-1111-111111111111ok5s agoid:1 preferred_derp:999 endpoints:"192.168.0.49:4449"
02000000-2222-2222-2222-22222222222202000000-1111-1111-1111-111111111111lost7s agoid:2 preferred_derp:999 endpoints:"192.168.0.33:4449"
+ +

# tunnels: total 1

+ + + + + + + + + + + + + +
SrcIDDstIDCoordinatorIDLast Write Age
01000000-2222-2222-2222-22222222222202000000-2222-2222-2222-22222222222201000000-1111-1111-1111-1111111111113s ago
+ + diff --git a/site/.eslintignore b/site/.eslintignore index 20570ccb94..033d259091 100644 --- a/site/.eslintignore +++ b/site/.eslintignore @@ -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 diff --git a/site/.prettierignore b/site/.prettierignore index 20570ccb94..033d259091 100644 --- a/site/.prettierignore +++ b/site/.prettierignore @@ -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