Skip to content

Commit c1ecc91

Browse files
authored
feat: Add fallback troubleshooting URL for coder agents (#5005)
1 parent 1f4f0ce commit c1ecc91

File tree

11 files changed

+161
-52
lines changed

11 files changed

+161
-52
lines changed

cli/cliui/agent_test.go

+48
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/coder/coder/cli/cliui"
1313
"github.com/coder/coder/codersdk"
1414
"github.com/coder/coder/pty/ptytest"
15+
"github.com/coder/coder/testutil"
1516
)
1617

1718
func TestAgent(t *testing.T) {
@@ -49,3 +50,50 @@ func TestAgent(t *testing.T) {
4950
disconnected.Store(true)
5051
<-done
5152
}
53+
54+
func TestAgentTimeoutWithTroubleshootingURL(t *testing.T) {
55+
t.Parallel()
56+
57+
ctx, _ := testutil.Context(t)
58+
59+
wantURL := "https://coder.com/troubleshoot"
60+
61+
var connected, timeout atomic.Bool
62+
cmd := &cobra.Command{
63+
RunE: func(cmd *cobra.Command, args []string) error {
64+
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
65+
WorkspaceName: "example",
66+
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
67+
agent := codersdk.WorkspaceAgent{
68+
Status: codersdk.WorkspaceAgentConnecting,
69+
TroubleshootingURL: "https://coder.com/troubleshoot",
70+
}
71+
switch {
72+
case connected.Load():
73+
agent.Status = codersdk.WorkspaceAgentConnected
74+
case timeout.Load():
75+
agent.Status = codersdk.WorkspaceAgentTimeout
76+
}
77+
return agent, nil
78+
},
79+
FetchInterval: time.Millisecond,
80+
WarnInterval: 5 * time.Millisecond,
81+
})
82+
return err
83+
},
84+
}
85+
ptty := ptytest.New(t)
86+
cmd.SetOutput(ptty.Output())
87+
cmd.SetIn(ptty.Input())
88+
done := make(chan struct{})
89+
go func() {
90+
defer close(done)
91+
err := cmd.ExecuteContext(ctx)
92+
assert.NoError(t, err)
93+
}()
94+
ptty.ExpectMatch("Don't panic")
95+
timeout.Store(true)
96+
ptty.ExpectMatch(wantURL)
97+
connected.Store(true)
98+
<-done
99+
}

cli/deployment/config.go

+7
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,13 @@ func newConfig() *codersdk.DeploymentConfig {
347347
Hidden: true,
348348
Default: 10 * time.Minute,
349349
},
350+
AgentFallbackTroubleshootingURL: &codersdk.DeploymentConfigField[string]{
351+
Name: "Agent Fallback Troubleshooting URL",
352+
Usage: "URL to use for agent troubleshooting when not set in the template",
353+
Flag: "agent-fallback-troubleshooting-url",
354+
Hidden: true,
355+
Default: "https://coder.com/docs/coder-oss/latest/templates#troubleshooting-templates",
356+
},
350357
AuditLogging: &codersdk.DeploymentConfigField[bool]{
351358
Name: "Audit Logging",
352359
Usage: "Specifies whether audit logging is enabled.",

coderd/provisionerjobs.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ func (api *API) provisionerJobResources(rw http.ResponseWriter, r *http.Request,
262262
}
263263
}
264264

265-
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), agent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout)
265+
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), agent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout, api.DeploymentConfig.AgentFallbackTroubleshootingURL.Value)
266266
if err != nil {
267267
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
268268
Message: "Internal error reading job agent.",

coderd/workspaceagents.go

+11-7
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) {
5252
})
5353
return
5454
}
55-
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout)
55+
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout, api.DeploymentConfig.AgentFallbackTroubleshootingURL.Value)
5656
if err != nil {
5757
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
5858
Message: "Internal error reading workspace agent.",
@@ -67,7 +67,7 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) {
6767
func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) {
6868
ctx := r.Context()
6969
workspaceAgent := httpmw.WorkspaceAgent(r)
70-
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout)
70+
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout, api.DeploymentConfig.AgentFallbackTroubleshootingURL.Value)
7171
if err != nil {
7272
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
7373
Message: "Internal error reading workspace agent.",
@@ -138,7 +138,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request)
138138
func (api *API) postWorkspaceAgentVersion(rw http.ResponseWriter, r *http.Request) {
139139
ctx := r.Context()
140140
workspaceAgent := httpmw.WorkspaceAgent(r)
141-
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout)
141+
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout, api.DeploymentConfig.AgentFallbackTroubleshootingURL.Value)
142142
if err != nil {
143143
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
144144
Message: "Internal error reading workspace agent.",
@@ -192,7 +192,7 @@ func (api *API) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
192192
httpapi.ResourceNotFound(rw)
193193
return
194194
}
195-
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout)
195+
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout, api.DeploymentConfig.AgentFallbackTroubleshootingURL.Value)
196196
if err != nil {
197197
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
198198
Message: "Internal error reading workspace agent.",
@@ -269,7 +269,7 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req
269269
return
270270
}
271271

272-
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout)
272+
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout, api.DeploymentConfig.AgentFallbackTroubleshootingURL.Value)
273273
if err != nil {
274274
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
275275
Message: "Internal error reading workspace agent.",
@@ -660,14 +660,18 @@ func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp {
660660
return apps
661661
}
662662

663-
func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator, dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, agentInactiveDisconnectTimeout time.Duration) (codersdk.WorkspaceAgent, error) {
663+
func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator, dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, agentInactiveDisconnectTimeout time.Duration, agentFallbackTroubleshootingURL string) (codersdk.WorkspaceAgent, error) {
664664
var envs map[string]string
665665
if dbAgent.EnvironmentVariables.Valid {
666666
err := json.Unmarshal(dbAgent.EnvironmentVariables.RawMessage, &envs)
667667
if err != nil {
668668
return codersdk.WorkspaceAgent{}, xerrors.Errorf("unmarshal env vars: %w", err)
669669
}
670670
}
671+
troubleshootingURL := agentFallbackTroubleshootingURL
672+
if dbAgent.TroubleshootingURL != "" {
673+
troubleshootingURL = dbAgent.TroubleshootingURL
674+
}
671675
workspaceAgent := codersdk.WorkspaceAgent{
672676
ID: dbAgent.ID,
673677
CreatedAt: dbAgent.CreatedAt,
@@ -683,7 +687,7 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordin
683687
Directory: dbAgent.Directory,
684688
Apps: apps,
685689
ConnectionTimeoutSeconds: dbAgent.ConnectionTimeoutSeconds,
686-
TroubleshootingURL: dbAgent.TroubleshootingURL,
690+
TroubleshootingURL: troubleshootingURL,
687691
}
688692
node := coordinator.Node(dbAgent.ID)
689693
if node != nil {

coderd/workspaceagents_test.go

+50-2
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,48 @@ func TestWorkspaceAgent(t *testing.T) {
7676
_, err = client.WorkspaceAgent(ctx, workspace.LatestBuild.Resources[0].Agents[0].ID)
7777
require.NoError(t, err)
7878
})
79+
t.Run("HasFallbackTroubleshootingURL", func(t *testing.T) {
80+
t.Parallel()
81+
client := coderdtest.New(t, &coderdtest.Options{
82+
IncludeProvisionerDaemon: true,
83+
})
84+
user := coderdtest.CreateFirstUser(t, client)
85+
authToken := uuid.NewString()
86+
tmpDir := t.TempDir()
87+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
88+
Parse: echo.ParseComplete,
89+
ProvisionPlan: echo.ProvisionComplete,
90+
ProvisionApply: []*proto.Provision_Response{{
91+
Type: &proto.Provision_Response_Complete{
92+
Complete: &proto.Provision_Complete{
93+
Resources: []*proto.Resource{{
94+
Name: "example",
95+
Type: "aws_instance",
96+
Agents: []*proto.Agent{{
97+
Id: uuid.NewString(),
98+
Directory: tmpDir,
99+
Auth: &proto.Agent_Token{
100+
Token: authToken,
101+
},
102+
}},
103+
}},
104+
},
105+
},
106+
}},
107+
})
108+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
109+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
110+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
111+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
112+
113+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
114+
defer cancel()
115+
116+
workspace, err := client.Workspace(ctx, workspace.ID)
117+
require.NoError(t, err)
118+
require.NotEmpty(t, workspace.LatestBuild.Resources[0].Agents[0].TroubleshootingURL)
119+
t.Log(workspace.LatestBuild.Resources[0].Agents[0].TroubleshootingURL)
120+
})
79121
t.Run("Timeout", func(t *testing.T) {
80122
t.Parallel()
81123
client := coderdtest.New(t, &coderdtest.Options{
@@ -84,6 +126,9 @@ func TestWorkspaceAgent(t *testing.T) {
84126
user := coderdtest.CreateFirstUser(t, client)
85127
authToken := uuid.NewString()
86128
tmpDir := t.TempDir()
129+
130+
wantTroubleshootingURL := "https://example.com/troubleshoot"
131+
87132
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
88133
Parse: echo.ParseComplete,
89134
ProvisionPlan: echo.ProvisionComplete,
@@ -100,7 +145,7 @@ func TestWorkspaceAgent(t *testing.T) {
100145
Token: authToken,
101146
},
102147
ConnectionTimeoutSeconds: 1,
103-
TroubleshootingUrl: "https://example.com/troubleshoot",
148+
TroubleshootingUrl: wantTroubleshootingURL,
104149
}},
105150
}},
106151
},
@@ -115,13 +160,16 @@ func TestWorkspaceAgent(t *testing.T) {
115160
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
116161
defer cancel()
117162

163+
var err error
118164
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
119-
workspace, err := client.Workspace(ctx, workspace.ID)
165+
workspace, err = client.Workspace(ctx, workspace.ID)
120166
if !assert.NoError(t, err) {
121167
return false
122168
}
123169
return workspace.LatestBuild.Resources[0].Agents[0].Status == codersdk.WorkspaceAgentTimeout
124170
}, testutil.IntervalMedium, "agent status timeout")
171+
172+
require.Equal(t, wantTroubleshootingURL, workspace.LatestBuild.Resources[0].Agents[0].TroubleshootingURL)
125173
})
126174
}
127175

coderd/workspacebuilds.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -902,7 +902,7 @@ func (api *API) convertWorkspaceBuild(
902902
apiAgents := make([]codersdk.WorkspaceAgent, 0)
903903
for _, agent := range agents {
904904
apps := appsByAgentID[agent.ID]
905-
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), agent, convertApps(apps), api.AgentInactiveDisconnectTimeout)
905+
apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), agent, convertApps(apps), api.AgentInactiveDisconnectTimeout, api.DeploymentConfig.AgentFallbackTroubleshootingURL.Value)
906906
if err != nil {
907907
return codersdk.WorkspaceBuild{}, xerrors.Errorf("converting workspace agent: %w", err)
908908
}

codersdk/deploymentconfig.go

+30-29
Original file line numberDiff line numberDiff line change
@@ -11,35 +11,36 @@ import (
1111

1212
// DeploymentConfig is the central configuration for the coder server.
1313
type DeploymentConfig struct {
14-
AccessURL *DeploymentConfigField[string] `json:"access_url" typescript:",notnull"`
15-
WildcardAccessURL *DeploymentConfigField[string] `json:"wildcard_access_url" typescript:",notnull"`
16-
Address *DeploymentConfigField[string] `json:"address" typescript:",notnull"`
17-
AutobuildPollInterval *DeploymentConfigField[time.Duration] `json:"autobuild_poll_interval" typescript:",notnull"`
18-
DERP *DERP `json:"derp" typescript:",notnull"`
19-
GitAuth *DeploymentConfigField[[]GitAuthConfig] `json:"gitauth" typescript:",notnull"`
20-
Prometheus *PrometheusConfig `json:"prometheus" typescript:",notnull"`
21-
Pprof *PprofConfig `json:"pprof" typescript:",notnull"`
22-
ProxyTrustedHeaders *DeploymentConfigField[[]string] `json:"proxy_trusted_headers" typescript:",notnull"`
23-
ProxyTrustedOrigins *DeploymentConfigField[[]string] `json:"proxy_trusted_origins" typescript:",notnull"`
24-
CacheDirectory *DeploymentConfigField[string] `json:"cache_directory" typescript:",notnull"`
25-
InMemoryDatabase *DeploymentConfigField[bool] `json:"in_memory_database" typescript:",notnull"`
26-
PostgresURL *DeploymentConfigField[string] `json:"pg_connection_url" typescript:",notnull"`
27-
OAuth2 *OAuth2Config `json:"oauth2" typescript:",notnull"`
28-
OIDC *OIDCConfig `json:"oidc" typescript:",notnull"`
29-
Telemetry *TelemetryConfig `json:"telemetry" typescript:",notnull"`
30-
TLS *TLSConfig `json:"tls" typescript:",notnull"`
31-
Trace *TraceConfig `json:"trace" typescript:",notnull"`
32-
SecureAuthCookie *DeploymentConfigField[bool] `json:"secure_auth_cookie" typescript:",notnull"`
33-
SSHKeygenAlgorithm *DeploymentConfigField[string] `json:"ssh_keygen_algorithm" typescript:",notnull"`
34-
AutoImportTemplates *DeploymentConfigField[[]string] `json:"auto_import_templates" typescript:",notnull"`
35-
MetricsCacheRefreshInterval *DeploymentConfigField[time.Duration] `json:"metrics_cache_refresh_interval" typescript:",notnull"`
36-
AgentStatRefreshInterval *DeploymentConfigField[time.Duration] `json:"agent_stat_refresh_interval" typescript:",notnull"`
37-
AuditLogging *DeploymentConfigField[bool] `json:"audit_logging" typescript:",notnull"`
38-
BrowserOnly *DeploymentConfigField[bool] `json:"browser_only" typescript:",notnull"`
39-
SCIMAPIKey *DeploymentConfigField[string] `json:"scim_api_key" typescript:",notnull"`
40-
Provisioner *ProvisionerConfig `json:"provisioner" typescript:",notnull"`
41-
APIRateLimit *DeploymentConfigField[int] `json:"api_rate_limit" typescript:",notnull"`
42-
Experimental *DeploymentConfigField[bool] `json:"experimental" typescript:",notnull"`
14+
AccessURL *DeploymentConfigField[string] `json:"access_url" typescript:",notnull"`
15+
WildcardAccessURL *DeploymentConfigField[string] `json:"wildcard_access_url" typescript:",notnull"`
16+
Address *DeploymentConfigField[string] `json:"address" typescript:",notnull"`
17+
AutobuildPollInterval *DeploymentConfigField[time.Duration] `json:"autobuild_poll_interval" typescript:",notnull"`
18+
DERP *DERP `json:"derp" typescript:",notnull"`
19+
GitAuth *DeploymentConfigField[[]GitAuthConfig] `json:"gitauth" typescript:",notnull"`
20+
Prometheus *PrometheusConfig `json:"prometheus" typescript:",notnull"`
21+
Pprof *PprofConfig `json:"pprof" typescript:",notnull"`
22+
ProxyTrustedHeaders *DeploymentConfigField[[]string] `json:"proxy_trusted_headers" typescript:",notnull"`
23+
ProxyTrustedOrigins *DeploymentConfigField[[]string] `json:"proxy_trusted_origins" typescript:",notnull"`
24+
CacheDirectory *DeploymentConfigField[string] `json:"cache_directory" typescript:",notnull"`
25+
InMemoryDatabase *DeploymentConfigField[bool] `json:"in_memory_database" typescript:",notnull"`
26+
PostgresURL *DeploymentConfigField[string] `json:"pg_connection_url" typescript:",notnull"`
27+
OAuth2 *OAuth2Config `json:"oauth2" typescript:",notnull"`
28+
OIDC *OIDCConfig `json:"oidc" typescript:",notnull"`
29+
Telemetry *TelemetryConfig `json:"telemetry" typescript:",notnull"`
30+
TLS *TLSConfig `json:"tls" typescript:",notnull"`
31+
Trace *TraceConfig `json:"trace" typescript:",notnull"`
32+
SecureAuthCookie *DeploymentConfigField[bool] `json:"secure_auth_cookie" typescript:",notnull"`
33+
SSHKeygenAlgorithm *DeploymentConfigField[string] `json:"ssh_keygen_algorithm" typescript:",notnull"`
34+
AutoImportTemplates *DeploymentConfigField[[]string] `json:"auto_import_templates" typescript:",notnull"`
35+
MetricsCacheRefreshInterval *DeploymentConfigField[time.Duration] `json:"metrics_cache_refresh_interval" typescript:",notnull"`
36+
AgentStatRefreshInterval *DeploymentConfigField[time.Duration] `json:"agent_stat_refresh_interval" typescript:",notnull"`
37+
AgentFallbackTroubleshootingURL *DeploymentConfigField[string] `json:"agent_fallback_troubleshooting_url" typescript:",notnull"`
38+
AuditLogging *DeploymentConfigField[bool] `json:"audit_logging" typescript:",notnull"`
39+
BrowserOnly *DeploymentConfigField[bool] `json:"browser_only" typescript:",notnull"`
40+
SCIMAPIKey *DeploymentConfigField[string] `json:"scim_api_key" typescript:",notnull"`
41+
Provisioner *ProvisionerConfig `json:"provisioner" typescript:",notnull"`
42+
APIRateLimit *DeploymentConfigField[int] `json:"api_rate_limit" typescript:",notnull"`
43+
Experimental *DeploymentConfigField[bool] `json:"experimental" typescript:",notnull"`
4344
}
4445

4546
type DERP struct {

codersdk/workspaceagents.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ type WorkspaceAgent struct {
5656
// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
5757
DERPLatency map[string]DERPRegion `json:"latency,omitempty"`
5858
ConnectionTimeoutSeconds int32 `json:"connection_timeout_seconds"`
59-
TroubleshootingURL string `json:"troubleshooting_url,omitempty"`
59+
TroubleshootingURL string `json:"troubleshooting_url"`
6060
}
6161

6262
type WorkspaceAgentResourceMetadata struct {

site/src/api/typesGenerated.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ export interface DeploymentConfig {
299299
readonly auto_import_templates: DeploymentConfigField<string[]>
300300
readonly metrics_cache_refresh_interval: DeploymentConfigField<number>
301301
readonly agent_stat_refresh_interval: DeploymentConfigField<number>
302+
readonly agent_fallback_troubleshooting_url: DeploymentConfigField<string>
302303
readonly audit_logging: DeploymentConfigField<boolean>
303304
readonly browser_only: DeploymentConfigField<boolean>
304305
readonly scim_api_key: DeploymentConfigField<string>
@@ -817,7 +818,7 @@ export interface WorkspaceAgent {
817818
readonly apps: WorkspaceApp[]
818819
readonly latency?: Record<string, DERPRegion>
819820
readonly connection_timeout_seconds: number
820-
readonly troubleshooting_url?: string
821+
readonly troubleshooting_url: string
821822
}
822823

823824
// From codersdk/workspaceagents.go

0 commit comments

Comments
 (0)