diff --git a/.prettierignore b/.prettierignore
index 011d66b70991e..37cbd3fef3973 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 3a42bc75ecf9f..fd7f94f13d136 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 72e44308c6f03..1240bb57b1eab 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/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 83ea22b628779..9fb52099e9f00 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -881,6 +881,27 @@ func (q *querier) GetAllTailnetClients(ctx context.Context) ([]database.GetAllTa
return q.db.GetAllTailnetClients(ctx)
}
+func (q *querier) GetAllTailnetCoordinators(ctx context.Context) ([]database.TailnetCoordinator, error) {
+ if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
+ return nil, err
+ }
+ return q.db.GetAllTailnetCoordinators(ctx)
+}
+
+func (q *querier) GetAllTailnetPeers(ctx context.Context) ([]database.TailnetPeer, error) {
+ if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
+ return nil, err
+ }
+ return q.db.GetAllTailnetPeers(ctx)
+}
+
+func (q *querier) GetAllTailnetTunnels(ctx context.Context) ([]database.TailnetTunnel, error) {
+ if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
+ return nil, err
+ }
+ return q.db.GetAllTailnetTunnels(ctx)
+}
+
func (q *querier) GetAppSecurityKey(ctx context.Context) (string, error) {
// No authz checks
return q.db.GetAppSecurityKey(ctx)
diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go
index b62ef08927bfd..3b78d67ebeba4 100644
--- a/coderd/database/dbmem/dbmem.go
+++ b/coderd/database/dbmem/dbmem.go
@@ -1276,6 +1276,18 @@ func (*FakeQuerier) GetAllTailnetClients(_ context.Context) ([]database.GetAllTa
return nil, ErrUnimplemented
}
+func (*FakeQuerier) GetAllTailnetCoordinators(context.Context) ([]database.TailnetCoordinator, error) {
+ return nil, ErrUnimplemented
+}
+
+func (*FakeQuerier) GetAllTailnetPeers(context.Context) ([]database.TailnetPeer, error) {
+ return nil, ErrUnimplemented
+}
+
+func (*FakeQuerier) GetAllTailnetTunnels(context.Context) ([]database.TailnetTunnel, error) {
+ return nil, ErrUnimplemented
+}
+
func (q *FakeQuerier) GetAppSecurityKey(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go
index 5d8d0f1030088..e37153463a24a 100644
--- a/coderd/database/dbmetrics/dbmetrics.go
+++ b/coderd/database/dbmetrics/dbmetrics.go
@@ -314,6 +314,27 @@ func (m metricsStore) GetAllTailnetClients(ctx context.Context) ([]database.GetA
return r0, r1
}
+func (m metricsStore) GetAllTailnetCoordinators(ctx context.Context) ([]database.TailnetCoordinator, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetAllTailnetCoordinators(ctx)
+ m.queryLatencies.WithLabelValues("GetAllTailnetCoordinators").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
+func (m metricsStore) GetAllTailnetPeers(ctx context.Context) ([]database.TailnetPeer, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetAllTailnetPeers(ctx)
+ m.queryLatencies.WithLabelValues("GetAllTailnetPeers").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
+func (m metricsStore) GetAllTailnetTunnels(ctx context.Context) ([]database.TailnetTunnel, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetAllTailnetTunnels(ctx)
+ m.queryLatencies.WithLabelValues("GetAllTailnetTunnels").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
func (m metricsStore) GetAppSecurityKey(ctx context.Context) (string, error) {
start := time.Now()
key, err := m.s.GetAppSecurityKey(ctx)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index 2bf37350efbaa..bcc03a77ddd1a 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -532,6 +532,51 @@ func (mr *MockStoreMockRecorder) GetAllTailnetClients(arg0 interface{}) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTailnetClients", reflect.TypeOf((*MockStore)(nil).GetAllTailnetClients), arg0)
}
+// GetAllTailnetCoordinators mocks base method.
+func (m *MockStore) GetAllTailnetCoordinators(arg0 context.Context) ([]database.TailnetCoordinator, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAllTailnetCoordinators", arg0)
+ ret0, _ := ret[0].([]database.TailnetCoordinator)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetAllTailnetCoordinators indicates an expected call of GetAllTailnetCoordinators.
+func (mr *MockStoreMockRecorder) GetAllTailnetCoordinators(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTailnetCoordinators", reflect.TypeOf((*MockStore)(nil).GetAllTailnetCoordinators), arg0)
+}
+
+// GetAllTailnetPeers mocks base method.
+func (m *MockStore) GetAllTailnetPeers(arg0 context.Context) ([]database.TailnetPeer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAllTailnetPeers", arg0)
+ ret0, _ := ret[0].([]database.TailnetPeer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetAllTailnetPeers indicates an expected call of GetAllTailnetPeers.
+func (mr *MockStoreMockRecorder) GetAllTailnetPeers(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTailnetPeers", reflect.TypeOf((*MockStore)(nil).GetAllTailnetPeers), arg0)
+}
+
+// GetAllTailnetTunnels mocks base method.
+func (m *MockStore) GetAllTailnetTunnels(arg0 context.Context) ([]database.TailnetTunnel, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAllTailnetTunnels", arg0)
+ ret0, _ := ret[0].([]database.TailnetTunnel)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetAllTailnetTunnels indicates an expected call of GetAllTailnetTunnels.
+func (mr *MockStoreMockRecorder) GetAllTailnetTunnels(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTailnetTunnels", reflect.TypeOf((*MockStore)(nil).GetAllTailnetTunnels), arg0)
+}
+
// GetAppSecurityKey mocks base method.
func (m *MockStore) GetAppSecurityKey(arg0 context.Context) (string, error) {
m.ctrl.T.Helper()
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 10c6be43a8179..d1b3e070d8b13 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -74,6 +74,10 @@ type sqlcQuerier interface {
GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error)
GetAllTailnetAgents(ctx context.Context) ([]TailnetAgent, error)
GetAllTailnetClients(ctx context.Context) ([]GetAllTailnetClientsRow, error)
+ // For PG Coordinator HTMLDebug
+ GetAllTailnetCoordinators(ctx context.Context) ([]TailnetCoordinator, error)
+ GetAllTailnetPeers(ctx context.Context) ([]TailnetPeer, error)
+ GetAllTailnetTunnels(ctx context.Context) ([]TailnetTunnel, error)
GetAppSecurityKey(ctx context.Context) (string, error)
GetApplicationName(ctx context.Context) (string, error)
// GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index c5aac3013fb11..254515c00811a 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -4754,6 +4754,100 @@ func (q *sqlQuerier) GetAllTailnetClients(ctx context.Context) ([]GetAllTailnetC
return items, nil
}
+const getAllTailnetCoordinators = `-- name: GetAllTailnetCoordinators :many
+
+SELECT id, heartbeat_at FROM tailnet_coordinators
+`
+
+// For PG Coordinator HTMLDebug
+func (q *sqlQuerier) GetAllTailnetCoordinators(ctx context.Context) ([]TailnetCoordinator, error) {
+ rows, err := q.db.QueryContext(ctx, getAllTailnetCoordinators)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []TailnetCoordinator
+ for rows.Next() {
+ var i TailnetCoordinator
+ if err := rows.Scan(&i.ID, &i.HeartbeatAt); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getAllTailnetPeers = `-- name: GetAllTailnetPeers :many
+SELECT id, coordinator_id, updated_at, node, status FROM tailnet_peers
+`
+
+func (q *sqlQuerier) GetAllTailnetPeers(ctx context.Context) ([]TailnetPeer, error) {
+ rows, err := q.db.QueryContext(ctx, getAllTailnetPeers)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []TailnetPeer
+ for rows.Next() {
+ var i TailnetPeer
+ if err := rows.Scan(
+ &i.ID,
+ &i.CoordinatorID,
+ &i.UpdatedAt,
+ &i.Node,
+ &i.Status,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getAllTailnetTunnels = `-- name: GetAllTailnetTunnels :many
+SELECT coordinator_id, src_id, dst_id, updated_at FROM tailnet_tunnels
+`
+
+func (q *sqlQuerier) GetAllTailnetTunnels(ctx context.Context) ([]TailnetTunnel, error) {
+ rows, err := q.db.QueryContext(ctx, getAllTailnetTunnels)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []TailnetTunnel
+ for rows.Next() {
+ var i TailnetTunnel
+ if err := rows.Scan(
+ &i.CoordinatorID,
+ &i.SrcID,
+ &i.DstID,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getTailnetAgents = `-- name: GetTailnetAgents :many
SELECT id, coordinator_id, updated_at, node
FROM tailnet_agents
diff --git a/coderd/database/queries/tailnet.sql b/coderd/database/queries/tailnet.sql
index 011bc6900f2e7..6128c944f9619 100644
--- a/coderd/database/queries/tailnet.sql
+++ b/coderd/database/queries/tailnet.sql
@@ -199,3 +199,14 @@ SELECT tailnet_tunnels.src_id as peer_id, tailnet_peers.coordinator_id, tailnet_
FROM tailnet_tunnels
INNER JOIN tailnet_peers ON tailnet_tunnels.src_id = tailnet_peers.id
WHERE tailnet_tunnels.dst_id = $1;
+
+-- For PG Coordinator HTMLDebug
+
+-- name: GetAllTailnetCoordinators :many
+SELECT * FROM tailnet_coordinators;
+
+-- name: GetAllTailnetPeers :many
+SELECT * FROM tailnet_peers;
+
+-- name: GetAllTailnetTunnels :many
+SELECT * FROM tailnet_tunnels;
diff --git a/enterprise/tailnet/htmldebug.go b/enterprise/tailnet/htmldebug.go
new file mode 100644
index 0000000000000..282c1bc9e543f
--- /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 3803b8cb20b6c..a999e5586b2dd 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 95481e6af3cc4..a580a5fedda75 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 0000000000000..8f6648c620bdf
--- /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 20570ccb94cfd..033d259091b67 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 20570ccb94cfd..033d259091b67 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