mirror of https://github.com/coder/coder.git
feat: add support package and accompanying tests (#12289)
This commit is contained in:
parent
2bf3c72948
commit
e57c101200
|
@ -44,6 +44,19 @@ type UpdateHealthSettings struct {
|
|||
DismissedHealthchecks []HealthSection `json:"dismissed_healthchecks"`
|
||||
}
|
||||
|
||||
func (c *Client) DebugHealth(ctx context.Context) (HealthcheckReport, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/debug/health", nil)
|
||||
if err != nil {
|
||||
return HealthcheckReport{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return HealthcheckReport{}, ReadBodyAsError(res)
|
||||
}
|
||||
var rpt HealthcheckReport
|
||||
return rpt, json.NewDecoder(res.Body).Decode(&rpt)
|
||||
}
|
||||
|
||||
func (c *Client) HealthSettings(ctx context.Context) (HealthSettings, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/debug/health/settings", nil)
|
||||
if err != nil {
|
||||
|
|
|
@ -0,0 +1,227 @@
|
|||
package support
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// Bundle is a set of information discovered about a deployment.
|
||||
// Even though we do attempt to sanitize data, it may still contain
|
||||
// sensitive information and should thus be treated as secret.
|
||||
type Bundle struct {
|
||||
Deployment Deployment `json:"deployment"`
|
||||
Network Network `json:"network"`
|
||||
Workspace Workspace `json:"workspace"`
|
||||
Logs []string `json:"logs"`
|
||||
}
|
||||
|
||||
type Deployment struct {
|
||||
BuildInfo *codersdk.BuildInfoResponse `json:"build"`
|
||||
Config *codersdk.DeploymentConfig `json:"config"`
|
||||
Experiments codersdk.Experiments `json:"experiments"`
|
||||
HealthReport *codersdk.HealthcheckReport `json:"health_report"`
|
||||
}
|
||||
|
||||
type Network struct {
|
||||
CoordinatorDebug string `json:"coordinator_debug"`
|
||||
TailnetDebug string `json:"tailnet_debug"`
|
||||
NetcheckLocal *codersdk.WorkspaceAgentConnectionInfo `json:"netcheck_local"`
|
||||
NetcheckRemote *codersdk.WorkspaceAgentConnectionInfo `json:"netcheck_remote"`
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
Workspace codersdk.Workspace `json:"workspace"`
|
||||
BuildLogs []codersdk.ProvisionerJobLog `json:"build_logs"`
|
||||
Agent codersdk.WorkspaceAgent `json:"agent"`
|
||||
AgentStartupLogs []codersdk.WorkspaceAgentLog `json:"startup_logs"`
|
||||
}
|
||||
|
||||
// Deps is a set of dependencies for discovering information
|
||||
type Deps struct {
|
||||
// Source from which to obtain information.
|
||||
Client *codersdk.Client
|
||||
// Log is where to log any informational or warning messages.
|
||||
Log slog.Logger
|
||||
// WorkspaceID is the optional workspace against which to run connection tests.
|
||||
WorkspaceID uuid.UUID
|
||||
// AgentID is the optional agent ID against which to run connection tests.
|
||||
// Defaults to the first agent of the workspace, if not specified.
|
||||
AgentID uuid.UUID
|
||||
}
|
||||
|
||||
func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger) Deployment {
|
||||
var d Deployment
|
||||
|
||||
bi, err := client.BuildInfo(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "fetch build info", slog.Error(err))
|
||||
} else {
|
||||
d.BuildInfo = &bi
|
||||
}
|
||||
|
||||
dc, err := client.DeploymentConfig(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "fetch deployment config", slog.Error(err))
|
||||
} else {
|
||||
d.Config = dc
|
||||
}
|
||||
|
||||
hr, err := client.DebugHealth(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "fetch health report", slog.Error(err))
|
||||
} else {
|
||||
d.HealthReport = &hr
|
||||
}
|
||||
|
||||
exp, err := client.Experiments(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "fetch experiments", slog.Error(err))
|
||||
} else {
|
||||
d.Experiments = exp
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func NetworkInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID) Network {
|
||||
var n Network
|
||||
|
||||
coordResp, err := client.Request(ctx, http.MethodGet, "/api/v2/debug/coordinator", nil)
|
||||
if err != nil {
|
||||
log.Error(ctx, "fetch coordinator debug page", slog.Error(err))
|
||||
} else {
|
||||
defer coordResp.Body.Close()
|
||||
bs, err := io.ReadAll(coordResp.Body)
|
||||
if err != nil {
|
||||
log.Error(ctx, "read coordinator debug page", slog.Error(err))
|
||||
} else {
|
||||
n.CoordinatorDebug = string(bs)
|
||||
}
|
||||
}
|
||||
|
||||
tailResp, err := client.Request(ctx, http.MethodGet, "/api/v2/debug/tailnet", nil)
|
||||
if err != nil {
|
||||
log.Error(ctx, "fetch tailnet debug page", slog.Error(err))
|
||||
} else {
|
||||
defer tailResp.Body.Close()
|
||||
bs, err := io.ReadAll(tailResp.Body)
|
||||
if err != nil {
|
||||
log.Error(ctx, "read tailnet debug page", slog.Error(err))
|
||||
} else {
|
||||
n.TailnetDebug = string(bs)
|
||||
}
|
||||
}
|
||||
|
||||
if agentID != uuid.Nil {
|
||||
connInfo, err := client.WorkspaceAgentConnectionInfo(ctx, agentID)
|
||||
if err != nil {
|
||||
log.Error(ctx, "fetch agent conn info", slog.Error(err), slog.F("agent_id", agentID.String()))
|
||||
} else {
|
||||
n.NetcheckLocal = &connInfo
|
||||
}
|
||||
} else {
|
||||
log.Warn(ctx, "agent id required for agent connection info")
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, workspaceID, agentID uuid.UUID) Workspace {
|
||||
var w Workspace
|
||||
|
||||
if workspaceID == uuid.Nil {
|
||||
log.Error(ctx, "no workspace id specified")
|
||||
return w
|
||||
}
|
||||
|
||||
if agentID == uuid.Nil {
|
||||
log.Error(ctx, "no agent id specified")
|
||||
}
|
||||
|
||||
ws, err := client.Workspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
log.Error(ctx, "fetch workspace", slog.Error(err), slog.F("workspace_id", workspaceID))
|
||||
return w
|
||||
}
|
||||
|
||||
w.Workspace = ws
|
||||
|
||||
buildLogCh, closer, err := client.WorkspaceBuildLogsAfter(ctx, ws.LatestBuild.ID, 0)
|
||||
if err != nil {
|
||||
log.Error(ctx, "fetch provisioner job logs", slog.Error(err), slog.F("job_id", ws.LatestBuild.Job.ID.String()))
|
||||
} else {
|
||||
defer closer.Close()
|
||||
for log := range buildLogCh {
|
||||
w.BuildLogs = append(w.BuildLogs, log)
|
||||
}
|
||||
}
|
||||
|
||||
if len(w.Workspace.LatestBuild.Resources) == 0 {
|
||||
log.Warn(ctx, "workspace build has no resources")
|
||||
return w
|
||||
}
|
||||
|
||||
agentLogCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agentID, 0, false)
|
||||
if err != nil {
|
||||
log.Error(ctx, "fetch agent startup logs", slog.Error(err), slog.F("agent_id", agentID.String()))
|
||||
} else {
|
||||
defer closer.Close()
|
||||
for logChunk := range agentLogCh {
|
||||
w.AgentStartupLogs = append(w.AgentStartupLogs, logChunk...)
|
||||
}
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// Run generates a support bundle with the given dependencies.
|
||||
func Run(ctx context.Context, d *Deps) (*Bundle, error) {
|
||||
var b Bundle
|
||||
if d.Client == nil {
|
||||
return nil, xerrors.Errorf("developer error: missing client!")
|
||||
}
|
||||
|
||||
authChecks := map[string]codersdk.AuthorizationCheck{
|
||||
"Read DeploymentValues": {
|
||||
Object: codersdk.AuthorizationObject{
|
||||
ResourceType: codersdk.ResourceDeploymentValues,
|
||||
},
|
||||
Action: string(rbac.ActionRead),
|
||||
},
|
||||
}
|
||||
|
||||
// Ensure we capture logs from the client.
|
||||
var logw strings.Builder
|
||||
d.Log.AppendSinks(sloghuman.Sink(&logw))
|
||||
d.Client.SetLogger(d.Log)
|
||||
defer func() {
|
||||
b.Logs = strings.Split(logw.String(), "\n")
|
||||
}()
|
||||
|
||||
authResp, err := d.Client.AuthCheck(ctx, codersdk.AuthorizationRequest{Checks: authChecks})
|
||||
if err != nil {
|
||||
return &b, xerrors.Errorf("check authorization: %w", err)
|
||||
}
|
||||
for k, v := range authResp {
|
||||
if !v {
|
||||
return &b, xerrors.Errorf("failed authorization check: cannot %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
b.Deployment = DeploymentInfo(ctx, d.Client, d.Log)
|
||||
b.Workspace = WorkspaceInfo(ctx, d.Client, d.Log, d.WorkspaceID, d.AgentID)
|
||||
b.Network = NetworkInfo(ctx, d.Client, d.Log, d.AgentID)
|
||||
|
||||
return &b, nil
|
||||
}
|
|
@ -0,0 +1,169 @@
|
|||
package support_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/support"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := coderdtest.DeploymentValues(t)
|
||||
cfg.Experiments = []string{"foo"}
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
DeploymentValues: cfg,
|
||||
Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))),
|
||||
})
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
ws, agt := setupWorkspaceAndAgent(ctx, t, client, db, admin)
|
||||
|
||||
bun, err := support.Run(ctx, &support.Deps{
|
||||
Client: client,
|
||||
Log: slogtest.Make(t, nil).Named("bundle").Leveled(slog.LevelDebug),
|
||||
WorkspaceID: ws.ID,
|
||||
AgentID: agt.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, bun)
|
||||
require.NotEmpty(t, bun.Deployment.BuildInfo)
|
||||
require.NotEmpty(t, bun.Deployment.Config)
|
||||
require.NotEmpty(t, bun.Deployment.Config.Options)
|
||||
assertSanitizedDeploymentConfig(t, bun.Deployment.Config)
|
||||
require.NotEmpty(t, bun.Deployment.HealthReport)
|
||||
require.NotEmpty(t, bun.Deployment.Experiments)
|
||||
require.NotEmpty(t, bun.Network.CoordinatorDebug)
|
||||
require.NotEmpty(t, bun.Network.TailnetDebug)
|
||||
require.NotNil(t, bun.Network.NetcheckLocal)
|
||||
require.NotNil(t, bun.Workspace.Workspace)
|
||||
require.NotEmpty(t, bun.Workspace.BuildLogs)
|
||||
require.NotNil(t, bun.Workspace.Agent)
|
||||
require.NotEmpty(t, bun.Workspace.AgentStartupLogs)
|
||||
require.NotEmpty(t, bun.Logs)
|
||||
})
|
||||
|
||||
t.Run("OK_NoAgent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := coderdtest.DeploymentValues(t)
|
||||
cfg.Experiments = []string{"foo"}
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentValues: cfg,
|
||||
Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))),
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
bun, err := support.Run(ctx, &support.Deps{
|
||||
Client: client,
|
||||
Log: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Named("bundle").Leveled(slog.LevelDebug),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, bun)
|
||||
require.NotEmpty(t, bun.Deployment.BuildInfo)
|
||||
require.NotEmpty(t, bun.Deployment.Config)
|
||||
require.NotEmpty(t, bun.Deployment.Config.Options)
|
||||
assertSanitizedDeploymentConfig(t, bun.Deployment.Config)
|
||||
require.NotEmpty(t, bun.Deployment.HealthReport)
|
||||
require.NotEmpty(t, bun.Deployment.Experiments)
|
||||
require.NotEmpty(t, bun.Network.CoordinatorDebug)
|
||||
require.NotEmpty(t, bun.Network.TailnetDebug)
|
||||
require.NotNil(t, bun.Workspace)
|
||||
require.NotEmpty(t, bun.Logs)
|
||||
})
|
||||
|
||||
t.Run("NoAuth", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))),
|
||||
})
|
||||
bun, err := support.Run(ctx, &support.Deps{
|
||||
Client: client,
|
||||
Log: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Named("bundle").Leveled(slog.LevelDebug),
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.NotNil(t, bun)
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
|
||||
require.NotEmpty(t, bun)
|
||||
require.NotEmpty(t, bun.Logs)
|
||||
})
|
||||
|
||||
t.Run("MissingPrivilege", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))),
|
||||
})
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
|
||||
bun, err := support.Run(ctx, &support.Deps{
|
||||
Client: memberClient,
|
||||
Log: slogtest.Make(t, nil).Named("bundle").Leveled(slog.LevelDebug),
|
||||
})
|
||||
require.ErrorContains(t, err, "failed authorization check")
|
||||
require.NotEmpty(t, bun)
|
||||
require.NotEmpty(t, bun.Logs)
|
||||
})
|
||||
}
|
||||
|
||||
func assertSanitizedDeploymentConfig(t *testing.T, dc *codersdk.DeploymentConfig) {
|
||||
t.Helper()
|
||||
for _, opt := range dc.Options {
|
||||
if opt.Annotations.IsSet("secret") {
|
||||
assert.Empty(t, opt.Value.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupWorkspaceAndAgent(ctx context.Context, t *testing.T, client *codersdk.Client, db database.Store, user codersdk.CreateFirstUserResponse) (codersdk.Workspace, codersdk.WorkspaceAgent) {
|
||||
wbr := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
ws, err := client.Workspace(ctx, wbr.Workspace.ID)
|
||||
require.NoError(t, err)
|
||||
agt := ws.LatestBuild.Resources[0].Agents[0]
|
||||
|
||||
// Insert a provisioner job log
|
||||
_, err = db.InsertProvisionerJobLogs(ctx, database.InsertProvisionerJobLogsParams{
|
||||
JobID: wbr.Build.JobID,
|
||||
CreatedAt: []time.Time{dbtime.Now()},
|
||||
Source: []database.LogSource{database.LogSourceProvisionerDaemon},
|
||||
Level: []database.LogLevel{database.LogLevelInfo},
|
||||
Stage: []string{"The World"},
|
||||
Output: []string{"Players"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Insert an agent log
|
||||
_, err = db.InsertWorkspaceAgentLogs(ctx, database.InsertWorkspaceAgentLogsParams{
|
||||
AgentID: agt.ID,
|
||||
CreatedAt: dbtime.Now(),
|
||||
Output: []string{"Bond, James Bond"},
|
||||
Level: []database.LogLevel{database.LogLevelInfo},
|
||||
LogSourceID: wbr.Build.JobID,
|
||||
OutputLength: 0o7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return ws, agt
|
||||
}
|
Loading…
Reference in New Issue