From 37885e2e82f7ad49281d569caf2568312fa52422 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Wed, 18 Sep 2024 21:47:53 +1000 Subject: [PATCH] fix: make cli respect deployment --docs-url (#14568) --- cli/cliui/agent.go | 65 ++++++++++--------- cli/dotfiles.go | 2 +- cli/open.go | 7 +- cli/ping.go | 23 ++++--- cli/portforward.go | 7 +- cli/rename.go | 4 +- cli/root.go | 20 ++++++ cli/server.go | 4 +- cli/speedtest.go | 17 +++-- cli/ssh.go | 10 ++- cli/testdata/coder_server_--help.golden | 2 +- cli/testdata/server-config.yaml.golden | 4 +- cli/vscodessh.go | 11 ++++ coderd/apidoc/docs.go | 3 + coderd/apidoc/swagger.json | 3 + coderd/appearance/appearance.go | 40 ++---------- codersdk/deployment.go | 38 +++++++++++ docs/reference/api/enterprise.md | 1 + docs/reference/api/schemas.md | 2 + docs/reference/cli/server.md | 11 ++-- .../cli/testdata/coder_server_--help.golden | 2 +- enterprise/coderd/appearance.go | 6 +- enterprise/coderd/appearance_test.go | 24 ++++++- site/src/api/api.ts | 1 + site/src/api/typesGenerated.ts | 1 + site/src/testHelpers/entities.ts | 1 + 26 files changed, 201 insertions(+), 108 deletions(-) diff --git a/cli/cliui/agent.go b/cli/cliui/agent.go index 889fdb982d..211074ff74 100644 --- a/cli/cliui/agent.go +++ b/cli/cliui/agent.go @@ -25,6 +25,7 @@ type AgentOptions struct { Fetch func(ctx context.Context, agentID uuid.UUID) (codersdk.WorkspaceAgent, error) FetchLogs func(ctx context.Context, agentID uuid.UUID, after int64, follow bool) (<-chan []codersdk.WorkspaceAgentLog, io.Closer, error) Wait bool // If true, wait for the agent to be ready (startup script). + DocsURL string } // Agent displays a spinning indicator that waits for a workspace agent to connect. @@ -119,7 +120,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO if agent.Status == codersdk.WorkspaceAgentTimeout { now := time.Now() sw.Log(now, codersdk.LogLevelInfo, "The workspace agent is having trouble connecting, wait for it to connect or restart your workspace.") - sw.Log(now, codersdk.LogLevelInfo, troubleshootingMessage(agent, "https://coder.com/docs/templates#agent-connection-issues")) + sw.Log(now, codersdk.LogLevelInfo, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#agent-connection-issues", opts.DocsURL))) for agent.Status == codersdk.WorkspaceAgentTimeout { if agent, err = fetch(); err != nil { return xerrors.Errorf("fetch: %w", err) @@ -224,13 +225,13 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO sw.Fail(stage, safeDuration(sw, agent.ReadyAt, agent.StartedAt)) // Use zero time (omitted) to separate these from the startup logs. sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script exited with an error and your workspace may be incomplete.") - sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/templates/troubleshooting#startup-script-exited-with-an-error")) + sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#startup-script-exited-with-an-error", opts.DocsURL))) default: switch { case agent.LifecycleState.Starting(): // Use zero time (omitted) to separate these from the startup logs. sw.Log(time.Time{}, codersdk.LogLevelWarn, "Notice: The startup scripts are still running and your workspace may be incomplete.") - sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/templates/troubleshooting#your-workspace-may-be-incomplete")) + sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#your-workspace-may-be-incomplete", opts.DocsURL))) // Note: We don't complete or fail the stage here, it's // intentionally left open to indicate this stage didn't // complete. @@ -252,7 +253,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO stage := "The workspace agent lost connection" sw.Start(stage) sw.Log(time.Now(), codersdk.LogLevelWarn, "Wait for it to reconnect or restart your workspace.") - sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/templates/troubleshooting#agent-connection-issues")) + sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#agent-connection-issues", opts.DocsURL))) disconnectedAt := agent.DisconnectedAt for agent.Status == codersdk.WorkspaceAgentDisconnected { @@ -351,16 +352,16 @@ func PeerDiagnostics(w io.Writer, d tailnet.PeerDiagnostics) { } type ConnDiags struct { - ConnInfo workspacesdk.AgentConnectionInfo - PingP2P bool - DisableDirect bool - LocalNetInfo *tailcfg.NetInfo - LocalInterfaces *healthsdk.InterfacesReport - AgentNetcheck *healthsdk.AgentNetcheckReport - ClientIPIsAWS bool - AgentIPIsAWS bool - Verbose bool - // TODO: More diagnostics + ConnInfo workspacesdk.AgentConnectionInfo + PingP2P bool + DisableDirect bool + LocalNetInfo *tailcfg.NetInfo + LocalInterfaces *healthsdk.InterfacesReport + AgentNetcheck *healthsdk.AgentNetcheckReport + ClientIPIsAWS bool + AgentIPIsAWS bool + Verbose bool + TroubleshootingURL string } func (d ConnDiags) Write(w io.Writer) { @@ -395,7 +396,7 @@ func (d ConnDiags) splitDiagnostics() (general, client, agent []string) { agent = append(agent, msg.Message) } if len(d.AgentNetcheck.Interfaces.Warnings) > 0 { - agent[len(agent)-1] += "\nhttps://coder.com/docs/networking/troubleshooting#low-mtu" + agent[len(agent)-1] += fmt.Sprintf("\n%s#low-mtu", d.TroubleshootingURL) } } @@ -404,7 +405,7 @@ func (d ConnDiags) splitDiagnostics() (general, client, agent []string) { client = append(client, msg.Message) } if len(d.LocalInterfaces.Warnings) > 0 { - client[len(client)-1] += "\nhttps://coder.com/docs/networking/troubleshooting#low-mtu" + client[len(client)-1] += fmt.Sprintf("\n%s#low-mtu", d.TroubleshootingURL) } } @@ -420,45 +421,45 @@ func (d ConnDiags) splitDiagnostics() (general, client, agent []string) { } if d.ConnInfo.DisableDirectConnections { - general = append(general, "❗ Your Coder administrator has blocked direct connections\n"+ - " https://coder.com/docs/networking/troubleshooting#disabled-deployment-wide") + general = append(general, + fmt.Sprintf("❗ Your Coder administrator has blocked direct connections\n %s#disabled-deployment-wide", d.TroubleshootingURL)) if !d.Verbose { return general, client, agent } } if !d.ConnInfo.DERPMap.HasSTUN() { - general = append(general, "❗ The DERP map is not configured to use STUN\n"+ - " https://coder.com/docs/networking/troubleshooting#no-stun-servers") + general = append(general, + fmt.Sprintf("❗ The DERP map is not configured to use STUN\n %s#no-stun-servers", d.TroubleshootingURL)) } else if d.LocalNetInfo != nil && !d.LocalNetInfo.UDP { - client = append(client, "Client could not connect to STUN over UDP\n"+ - " https://coder.com/docs/networking/troubleshooting#udp-blocked") + client = append(client, + fmt.Sprintf("Client could not connect to STUN over UDP\n %s#udp-blocked", d.TroubleshootingURL)) } if d.LocalNetInfo != nil && d.LocalNetInfo.MappingVariesByDestIP.EqualBool(true) { - client = append(client, "Client is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers\n"+ - " https://coder.com/docs/networking/troubleshooting#Endpoint-Dependent-Nat-Hard-NAT") + client = append(client, + fmt.Sprintf("Client is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL)) } if d.AgentNetcheck != nil && d.AgentNetcheck.NetInfo != nil { if d.AgentNetcheck.NetInfo.MappingVariesByDestIP.EqualBool(true) { - agent = append(agent, "Agent is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers\n"+ - " https://coder.com/docs/networking/troubleshooting#Endpoint-Dependent-Nat-Hard-NAT") + agent = append(agent, + fmt.Sprintf("Agent is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL)) } if !d.AgentNetcheck.NetInfo.UDP { - agent = append(agent, "Agent could not connect to STUN over UDP\n"+ - " https://coder.com/docs/networking/troubleshooting#udp-blocked") + agent = append(agent, + fmt.Sprintf("Agent could not connect to STUN over UDP\n %s#udp-blocked", d.TroubleshootingURL)) } } if d.ClientIPIsAWS { - client = append(client, "Client IP address is within an AWS range (AWS uses hard NAT)\n"+ - " https://coder.com/docs/networking/troubleshooting#Endpoint-Dependent-Nat-Hard-NAT") + client = append(client, + fmt.Sprintf("Client IP address is within an AWS range (AWS uses hard NAT)\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL)) } if d.AgentIPIsAWS { - agent = append(agent, "Agent IP address is within an AWS range (AWS uses hard NAT)\n"+ - " https://coder.com/docs/networking/troubleshooting#Endpoint-Dependent-Nat-Hard-NAT") + agent = append(agent, + fmt.Sprintf("Agent IP address is within an AWS range (AWS uses hard NAT)\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL)) } return general, client, agent } diff --git a/cli/dotfiles.go b/cli/dotfiles.go index 472c4c8a03..97b323f83c 100644 --- a/cli/dotfiles.go +++ b/cli/dotfiles.go @@ -203,7 +203,7 @@ func (r *RootCmd) dotfiles() *serpent.Command { } if fi.Mode()&0o111 == 0 { - return xerrors.Errorf("script %q is not executable. See https://coder.com/docs/dotfiles for information on how to resolve the issue.", script) + return xerrors.Errorf("script %q does not have execute permissions", script) } // it is safe to use a variable command here because it's from diff --git a/cli/open.go b/cli/open.go index 9b51469ec7..09883684a7 100644 --- a/cli/open.go +++ b/cli/open.go @@ -35,8 +35,9 @@ const vscodeDesktopName = "VS Code Desktop" func (r *RootCmd) openVSCode() *serpent.Command { var ( - generateToken bool - testOpenError bool + generateToken bool + testOpenError bool + appearanceConfig codersdk.AppearanceConfig ) client := new(codersdk.Client) @@ -47,6 +48,7 @@ func (r *RootCmd) openVSCode() *serpent.Command { Middleware: serpent.Chain( serpent.RequireRangeArgs(1, 2), r.InitClient(client), + initAppearance(client, &appearanceConfig), ), Handler: func(inv *serpent.Invocation) error { ctx, cancel := context.WithCancel(inv.Context()) @@ -79,6 +81,7 @@ func (r *RootCmd) openVSCode() *serpent.Command { Fetch: client.WorkspaceAgent, FetchLogs: nil, Wait: false, + DocsURL: appearanceConfig.DocsURL, }) if err != nil { if xerrors.Is(err, context.Canceled) { diff --git a/cli/ping.go b/cli/ping.go index cadb69b5c2..4ce9cd5373 100644 --- a/cli/ping.go +++ b/cli/ping.go @@ -26,9 +26,10 @@ import ( func (r *RootCmd) ping() *serpent.Command { var ( - pingNum int64 - pingTimeout time.Duration - pingWait time.Duration + pingNum int64 + pingTimeout time.Duration + pingWait time.Duration + appearanceConfig codersdk.AppearanceConfig ) client := new(codersdk.Client) @@ -39,6 +40,7 @@ func (r *RootCmd) ping() *serpent.Command { Middleware: serpent.Chain( serpent.RequireNArgs(1), r.InitClient(client), + initAppearance(client, &appearanceConfig), ), Handler: func(inv *serpent.Invocation) error { ctx, cancel := context.WithCancel(inv.Context()) @@ -67,8 +69,8 @@ func (r *RootCmd) ping() *serpent.Command { if !r.disableNetworkTelemetry { opts.EnableTelemetry = true } - client := workspacesdk.New(client) - conn, err := client.DialAgent(ctx, workspaceAgent.ID, opts) + wsClient := workspacesdk.New(client) + conn, err := wsClient.DialAgent(ctx, workspaceAgent.ID, opts) if err != nil { return err } @@ -155,10 +157,11 @@ func (r *RootCmd) ping() *serpent.Command { ni := conn.GetNetInfo() connDiags := cliui.ConnDiags{ - PingP2P: didP2p, - DisableDirect: r.disableDirect, - LocalNetInfo: ni, - Verbose: r.verbose, + PingP2P: didP2p, + DisableDirect: r.disableDirect, + LocalNetInfo: ni, + Verbose: r.verbose, + TroubleshootingURL: appearanceConfig.DocsURL + "/networking/troubleshooting", } awsRanges, err := cliutil.FetchAWSIPRanges(diagCtx, cliutil.AWSIPRangesURL) @@ -168,7 +171,7 @@ func (r *RootCmd) ping() *serpent.Command { connDiags.ClientIPIsAWS = isAWSIP(awsRanges, ni) - connInfo, err := client.AgentConnectionInfoGeneric(diagCtx) + connInfo, err := wsClient.AgentConnectionInfoGeneric(diagCtx) if err != nil || connInfo.DERPMap == nil { return xerrors.Errorf("Failed to retrieve connection info from server: %w\n", err) } diff --git a/cli/portforward.go b/cli/portforward.go index bab85464a9..3af3a1ca84 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -29,6 +29,7 @@ func (r *RootCmd) portForward() *serpent.Command { tcpForwards []string // : udpForwards []string // : disableAutostart bool + appearanceConfig codersdk.AppearanceConfig ) client := new(codersdk.Client) cmd := &serpent.Command{ @@ -60,6 +61,7 @@ func (r *RootCmd) portForward() *serpent.Command { Middleware: serpent.Chain( serpent.RequireNArgs(1), r.InitClient(client), + initAppearance(client, &appearanceConfig), ), Handler: func(inv *serpent.Invocation) error { ctx, cancel := context.WithCancel(inv.Context()) @@ -88,8 +90,9 @@ func (r *RootCmd) portForward() *serpent.Command { } err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{ - Fetch: client.WorkspaceAgent, - Wait: false, + Fetch: client.WorkspaceAgent, + Wait: false, + DocsURL: appearanceConfig.DocsURL, }) if err != nil { return xerrors.Errorf("await agent: %w", err) diff --git a/cli/rename.go b/cli/rename.go index d18268d8e6..3bafa176d2 100644 --- a/cli/rename.go +++ b/cli/rename.go @@ -13,6 +13,7 @@ import ( ) func (r *RootCmd) rename() *serpent.Command { + var appearanceConfig codersdk.AppearanceConfig client := new(codersdk.Client) cmd := &serpent.Command{ Annotations: workspaceCommand, @@ -21,6 +22,7 @@ func (r *RootCmd) rename() *serpent.Command { Middleware: serpent.Chain( serpent.RequireNArgs(2), r.InitClient(client), + initAppearance(client, &appearanceConfig), ), Handler: func(inv *serpent.Invocation) error { workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) @@ -31,7 +33,7 @@ func (r *RootCmd) rename() *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "%s\n\n", pretty.Sprint(cliui.DefaultStyles.Wrap, "WARNING: A rename can result in data loss if a resource references the workspace name in the template (e.g volumes). Please backup any data before proceeding."), ) - _, _ = fmt.Fprintf(inv.Stdout, "See: %s\n\n", "https://coder.com/docs/templates/resource-persistence#%EF%B8%8F-persistence-pitfalls") + _, _ = fmt.Fprintf(inv.Stdout, "See: %s%s\n\n", appearanceConfig.DocsURL, "/templates/resource-persistence#%EF%B8%8F-persistence-pitfalls") _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: fmt.Sprintf("Type %q to confirm rename:", workspace.Name), Validate: func(s string) error { diff --git a/cli/root.go b/cli/root.go index b2be952cf0..085c5e1d7f 100644 --- a/cli/root.go +++ b/cli/root.go @@ -692,6 +692,26 @@ func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier str return client.WorkspaceByOwnerAndName(ctx, owner, name, codersdk.WorkspaceOptions{}) } +func initAppearance(client *codersdk.Client, outConfig *codersdk.AppearanceConfig) serpent.MiddlewareFunc { + return func(next serpent.HandlerFunc) serpent.HandlerFunc { + return func(inv *serpent.Invocation) error { + var err error + cfg, err := client.Appearance(inv.Context()) + if err != nil { + var sdkErr *codersdk.Error + if !(xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound) { + return err + } + } + if cfg.DocsURL == "" { + cfg.DocsURL = codersdk.DefaultDocsURL() + } + *outConfig = cfg + return next(inv) + } + } +} + // createConfig consumes the global configuration flag to produce a config root. func (r *RootCmd) createConfig() config.Root { return config.Root(r.globalConfig) diff --git a/cli/server.go b/cli/server.go index 1440d549ed..9157de30ef 100644 --- a/cli/server.go +++ b/cli/server.go @@ -628,7 +628,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. "new version of coder available", slog.F("new_version", r.Version), slog.F("url", r.URL), - slog.F("upgrade_instructions", "https://coder.com/docs/admin/upgrade"), + slog.F("upgrade_instructions", fmt.Sprintf("%s/admin/upgrade", vals.DocsURL.String())), ) } }, @@ -854,7 +854,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } defer options.Telemetry.Close() } else { - logger.Warn(ctx, `telemetry disabled, unable to notify of security issues. Read more: https://coder.com/docs/admin/telemetry`) + logger.Warn(ctx, fmt.Sprintf(`telemetry disabled, unable to notify of security issues. Read more: %s/admin/telemetry`, vals.DocsURL.String())) } // This prevents the pprof import from being accidentally deleted. diff --git a/cli/speedtest.go b/cli/speedtest.go index c31fc8e65d..0d9f839d6b 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -36,11 +36,12 @@ type speedtestTableItem struct { func (r *RootCmd) speedtest() *serpent.Command { var ( - direct bool - duration time.Duration - direction string - pcapFile string - formatter = cliui.NewOutputFormatter( + direct bool + duration time.Duration + direction string + pcapFile string + appearanceConfig codersdk.AppearanceConfig + formatter = cliui.NewOutputFormatter( cliui.ChangeFormatterData(cliui.TableFormat([]speedtestTableItem{}, []string{"Interval", "Throughput"}), func(data any) (any, error) { res, ok := data.(SpeedtestResult) if !ok { @@ -72,6 +73,7 @@ func (r *RootCmd) speedtest() *serpent.Command { Middleware: serpent.Chain( serpent.RequireNArgs(1), r.InitClient(client), + initAppearance(client, &appearanceConfig), ), Handler: func(inv *serpent.Invocation) error { ctx, cancel := context.WithCancel(inv.Context()) @@ -87,8 +89,9 @@ func (r *RootCmd) speedtest() *serpent.Command { } err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{ - Fetch: client.WorkspaceAgent, - Wait: false, + Fetch: client.WorkspaceAgent, + Wait: false, + DocsURL: appearanceConfig.DocsURL, }) if err != nil { return xerrors.Errorf("await agent: %w", err) diff --git a/cli/ssh.go b/cli/ssh.go index e63c857fad..7df590946f 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -67,6 +67,7 @@ func (r *RootCmd) ssh() *serpent.Command { env []string usageApp string disableAutostart bool + appearanceConfig codersdk.AppearanceConfig ) client := new(codersdk.Client) cmd := &serpent.Command{ @@ -76,6 +77,7 @@ func (r *RootCmd) ssh() *serpent.Command { Middleware: serpent.Chain( serpent.RequireNArgs(1), r.InitClient(client), + initAppearance(client, &appearanceConfig), ), Handler: func(inv *serpent.Invocation) (retErr error) { // Before dialing the SSH server over TCP, capture Interrupt signals @@ -230,9 +232,11 @@ func (r *RootCmd) ssh() *serpent.Command { // OpenSSH passes stderr directly to the calling TTY. // This is required in "stdio" mode so a connecting indicator can be displayed. err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{ - Fetch: client.WorkspaceAgent, - FetchLogs: client.WorkspaceAgentLogsAfter, - Wait: wait, + FetchInterval: 0, + Fetch: client.WorkspaceAgent, + FetchLogs: client.WorkspaceAgentLogsAfter, + Wait: wait, + DocsURL: appearanceConfig.DocsURL, }) if err != nil { if xerrors.Is(err, context.Canceled) { diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 615facfd28..1387e31710 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -175,7 +175,7 @@ NETWORKING OPTIONS: --access-url url, $CODER_ACCESS_URL The URL that users will use to access the Coder deployment. - --docs-url url, $CODER_DOCS_URL + --docs-url url, $CODER_DOCS_URL (default: https://coder.com/docs) Specifies the custom docs URL. --proxy-trusted-headers string-array, $CODER_PROXY_TRUSTED_HEADERS diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index bab8564230..78c893c58a 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -7,8 +7,8 @@ networking: # (default: , type: string) wildcardAccessURL: "" # Specifies the custom docs URL. - # (default: , type: url) - docsURL: + # (default: https://coder.com/docs, type: url) + docsURL: https://coder.com/docs # Specifies whether to redirect requests that do not match the access URL host. # (default: , type: bool) redirectToAccessURL: false diff --git a/cli/vscodessh.go b/cli/vscodessh.go index 193658716f..d64e49c674 100644 --- a/cli/vscodessh.go +++ b/cli/vscodessh.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "net/http" "net/url" "os" "path/filepath" @@ -133,10 +134,20 @@ func (r *RootCmd) vscodeSSH() *serpent.Command { return xerrors.Errorf("unknown wait value %q", waitEnum) } + appearanceCfg, err := client.Appearance(ctx) + if err != nil { + var sdkErr *codersdk.Error + if !(xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound) { + return xerrors.Errorf("get appearance config: %w", err) + } + appearanceCfg.DocsURL = codersdk.DefaultDocsURL() + } + err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{ Fetch: client.WorkspaceAgent, FetchLogs: client.WorkspaceAgentLogsAfter, Wait: wait, + DocsURL: appearanceCfg.DocsURL, }) if err != nil { if xerrors.Is(err, context.Canceled) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 020cb37519..bd951f8485 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8890,6 +8890,9 @@ const docTemplate = `{ "application_name": { "type": "string" }, + "docs_url": { + "type": "string" + }, "logo_url": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a61cc7bce8..c7c5bb1a16 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7880,6 +7880,9 @@ "application_name": { "type": "string" }, + "docs_url": { + "type": "string" + }, "logo_url": { "type": "string" }, diff --git a/coderd/appearance/appearance.go b/coderd/appearance/appearance.go index a22380a2d5..f63cd77a59 100644 --- a/coderd/appearance/appearance.go +++ b/coderd/appearance/appearance.go @@ -2,10 +2,7 @@ package appearance import ( "context" - "fmt" - "strings" - "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/codersdk" ) @@ -13,37 +10,6 @@ type Fetcher interface { Fetch(ctx context.Context) (codersdk.AppearanceConfig, error) } -func DefaultSupportLinks(docsURL string) []codersdk.LinkConfig { - version := buildinfo.Version() - if docsURL == "" { - docsURL = "https://coder.com/docs/@" + strings.Split(version, "-")[0] - } - buildInfo := fmt.Sprintf("Version: [`%s`](%s)", version, buildinfo.ExternalURL()) - - return []codersdk.LinkConfig{ - { - Name: "Documentation", - Target: docsURL, - Icon: "docs", - }, - { - Name: "Report a bug", - Target: "https://github.com/coder/coder/issues/new?labels=needs+grooming&body=" + buildInfo, - Icon: "bug", - }, - { - Name: "Join the Coder Discord", - Target: "https://coder.com/chat?utm_source=coder&utm_medium=coder&utm_campaign=server-footer", - Icon: "chat", - }, - { - Name: "Star the Repo", - Target: "https://github.com/coder/coder", - Icon: "star", - }, - } -} - type AGPLFetcher struct { docsURL string } @@ -51,11 +17,15 @@ type AGPLFetcher struct { func (f AGPLFetcher) Fetch(context.Context) (codersdk.AppearanceConfig, error) { return codersdk.AppearanceConfig{ AnnouncementBanners: []codersdk.BannerConfig{}, - SupportLinks: DefaultSupportLinks(f.docsURL), + SupportLinks: codersdk.DefaultSupportLinks(f.docsURL), + DocsURL: f.docsURL, }, nil } func NewDefaultFetcher(docsURL string) Fetcher { + if docsURL == "" { + docsURL = codersdk.DefaultDocsURL() + } return &AGPLFetcher{ docsURL: docsURL, } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 500d648b06..e8b90a07af 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -776,6 +776,42 @@ func DefaultCacheDir() string { return filepath.Join(defaultCacheDir, "coder") } +func DefaultSupportLinks(docsURL string) []LinkConfig { + version := buildinfo.Version() + buildInfo := fmt.Sprintf("Version: [`%s`](%s)", version, buildinfo.ExternalURL()) + + return []LinkConfig{ + { + Name: "Documentation", + Target: docsURL, + Icon: "docs", + }, + { + Name: "Report a bug", + Target: "https://github.com/coder/coder/issues/new?labels=needs+grooming&body=" + buildInfo, + Icon: "bug", + }, + { + Name: "Join the Coder Discord", + Target: "https://coder.com/chat?utm_source=coder&utm_medium=coder&utm_campaign=server-footer", + Icon: "chat", + }, + { + Name: "Star the Repo", + Target: "https://github.com/coder/coder", + Icon: "star", + }, + } +} + +func DefaultDocsURL() string { + version := strings.Split(buildinfo.Version(), "-")[0] + if version == "v0.0.0" { + return "https://coder.com/docs" + } + return "https://coder.com/docs/@" + version +} + // DeploymentConfig contains both the deployment values and how they're set. type DeploymentConfig struct { Values *DeploymentValues `json:"config,omitempty"` @@ -994,6 +1030,7 @@ when required by your organization's security policy.`, Name: "Docs URL", Description: "Specifies the custom docs URL.", Value: &c.DocsURL, + Default: DefaultDocsURL(), Flag: "docs-url", Env: "CODER_DOCS_URL", Group: &deploymentGroupNetworking, @@ -2737,6 +2774,7 @@ func (c *Client) DeploymentStats(ctx context.Context) (DeploymentStats, error) { type AppearanceConfig struct { ApplicationName string `json:"application_name"` LogoURL string `json:"logo_url"` + DocsURL string `json:"docs_url"` // Deprecated: ServiceBanner has been replaced by AnnouncementBanners. ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index d7f9a28803..32dab3dc43 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -27,6 +27,7 @@ curl -X GET http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", + "docs_url": "string", "logo_url": "string", "service_banner": { "background_color": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 2ba910c70d..9a53d3bfcb 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -391,6 +391,7 @@ } ], "application_name": "string", + "docs_url": "string", "logo_url": "string", "service_banner": { "background_color": "string", @@ -413,6 +414,7 @@ | ---------------------- | ------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------- | | `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | | `application_name` | string | false | | | +| `docs_url` | string | false | | | | `logo_url` | string | false | | | | `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | | `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index afffd1b657..17906465d2 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -43,11 +43,12 @@ Specifies the wildcard hostname to use for workspace applications in the form "\ ### --docs-url -| | | -| ----------- | ------------------------------- | -| Type | url | -| Environment | $CODER_DOCS_URL | -| YAML | networking.docsURL | +| | | +| ----------- | ----------------------------------- | +| Type | url | +| Environment | $CODER_DOCS_URL | +| YAML | networking.docsURL | +| Default | https://coder.com/docs | Specifies the custom docs URL. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 6d1399be7b..95c0c957d8 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -176,7 +176,7 @@ NETWORKING OPTIONS: --access-url url, $CODER_ACCESS_URL The URL that users will use to access the Coder deployment. - --docs-url url, $CODER_DOCS_URL + --docs-url url, $CODER_DOCS_URL (default: https://coder.com/docs) Specifies the custom docs URL. --proxy-trusted-headers string-array, $CODER_PROXY_TRUSTED_HEADERS diff --git a/enterprise/coderd/appearance.go b/enterprise/coderd/appearance.go index 80150914bd..6bb7ef6bc8 100644 --- a/enterprise/coderd/appearance.go +++ b/enterprise/coderd/appearance.go @@ -49,6 +49,9 @@ type appearanceFetcher struct { } func newAppearanceFetcher(store database.Store, links []codersdk.LinkConfig, docsURL, coderVersion string) agpl.Fetcher { + if docsURL == "" { + docsURL = codersdk.DefaultDocsURL() + } return &appearanceFetcher{ database: store, supportLinks: links, @@ -94,7 +97,8 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi ApplicationName: applicationName, LogoURL: logoURL, AnnouncementBanners: []codersdk.BannerConfig{}, - SupportLinks: agpl.DefaultSupportLinks(f.docsURL), + SupportLinks: codersdk.DefaultSupportLinks(f.docsURL), + DocsURL: f.docsURL, } if announcementBannersJSON != "" { diff --git a/enterprise/coderd/appearance_test.go b/enterprise/coderd/appearance_test.go index 895a2ecab1..e3563aa882 100644 --- a/enterprise/coderd/appearance_test.go +++ b/enterprise/coderd/appearance_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/agent/proto" - "github.com/coder/coder/v2/coderd/appearance" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbfake" @@ -230,6 +229,25 @@ func TestCustomSupportLinks(t *testing.T) { require.Equal(t, supportLinks, appr.SupportLinks) } +func TestCustomDocsURL(t *testing.T) { + t.Parallel() + + testURLRawString := "http://google.com" + testURL, err := url.Parse(testURLRawString) + require.NoError(t, err) + cfg := coderdtest.DeploymentValues(t) + cfg.DocsURL = *serpent.URLOf(testURL) + adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true, Options: &coderdtest.Options{DeploymentValues: cfg}}) + anotherClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + appr, err := anotherClient.Appearance(ctx) + require.NoError(t, err) + require.Equal(t, testURLRawString, appr.DocsURL) +} + func TestDefaultSupportLinksWithCustomDocsUrl(t *testing.T) { t.Parallel() @@ -247,7 +265,7 @@ func TestDefaultSupportLinksWithCustomDocsUrl(t *testing.T) { appr, err := anotherClient.Appearance(ctx) require.NoError(t, err) - require.Equal(t, appearance.DefaultSupportLinks(testURLRawString), appr.SupportLinks) + require.Equal(t, codersdk.DefaultSupportLinks(testURLRawString), appr.SupportLinks) } func TestDefaultSupportLinks(t *testing.T) { @@ -262,5 +280,5 @@ func TestDefaultSupportLinks(t *testing.T) { appr, err := anotherClient.Appearance(ctx) require.NoError(t, err) - require.Equal(t, appearance.DefaultSupportLinks(""), appr.SupportLinks) + require.Equal(t, codersdk.DefaultSupportLinks(codersdk.DefaultDocsURL()), appr.SupportLinks) } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b7929107c7..f337dfb13b 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1801,6 +1801,7 @@ class ApiMethods { if (isAxiosError(ex) && ex.response?.status === 404) { return { application_name: "", + docs_url: "", logo_url: "", announcement_banners: [], service_banner: { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bba9130bcc..41664495a5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -48,6 +48,7 @@ export interface AppHostResponse { export interface AppearanceConfig { readonly application_name: string; readonly logo_url: string; + readonly docs_url: string; readonly service_banner: BannerConfig; readonly announcement_banners: Readonly>; readonly support_links?: Readonly>; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7a6e0b1fd8..13baf9449a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2713,6 +2713,7 @@ export const MockAppearanceConfig: TypesGen.AppearanceConfig = { enabled: false, }, announcement_banners: [], + docs_url: "https://coder.com/docs/@main/", }; export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = {