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 }}
+
+
+ ID |
+ Heartbeat Age |
+
+ {{- range .Coordinators}}
+
+ {{ .ID }} |
+ {{ .HeartbeatAge }} ago |
+
+ {{- end }}
+
+
+ # peers: total {{ len .Peers }}
+
+
+ ID |
+ CoordinatorID |
+ Status |
+ Last Write Age |
+ Node |
+
+ {{- range .Peers }}
+
+ {{ .ID }} |
+ {{ .CoordinatorID }} |
+ {{ .Status }} |
+ {{ .LastWriteAge }} ago |
+ {{ .Node }} |
+
+ {{- end }}
+
+
+ # tunnels: total {{ len .Tunnels }}
+
+
+ SrcID |
+ DstID |
+ CoordinatorID |
+ Last Write Age |
+
+ {{- range .Tunnels }}
+
+ {{ .SrcID }} |
+ {{ .DstID }} |
+ {{ .CoordinatorID }} |
+ {{ .LastWriteAge }} ago |
+
+ {{- end }}
+
+
+
+`
+
+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
+
+
+ ID |
+ Heartbeat Age |
+
+
+ 01000000-1111-1111-1111-111111111111 |
+ 2s ago |
+
+
+ 02000000-1111-1111-1111-111111111111 |
+ 1s ago |
+
+
+
+ # peers: total 2
+
+
+ ID |
+ CoordinatorID |
+ Status |
+ Last Write Age |
+ Node |
+
+
+ 01000000-2222-2222-2222-222222222222 |
+ 01000000-1111-1111-1111-111111111111 |
+ ok |
+ 5s ago |
+ id:1 preferred_derp:999 endpoints:"192.168.0.49:4449" |
+
+
+ 02000000-2222-2222-2222-222222222222 |
+ 02000000-1111-1111-1111-111111111111 |
+ lost |
+ 7s ago |
+ id:2 preferred_derp:999 endpoints:"192.168.0.33:4449" |
+
+
+
+ # tunnels: total 1
+
+
+ SrcID |
+ DstID |
+ CoordinatorID |
+ Last Write Age |
+
+
+ 01000000-2222-2222-2222-222222222222 |
+ 02000000-2222-2222-2222-222222222222 |
+ 01000000-1111-1111-1111-111111111111 |
+ 3s 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