Skip to content

Commit bf6fb46

Browse files
committed
Merge branch 'main' into delete-template/presleyp/3688
2 parents d201301 + 5362f46 commit bf6fb46

File tree

106 files changed

+2865
-445
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

106 files changed

+2865
-445
lines changed

.envrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
use nix

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ build: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name
5656
.PHONY: build
5757

5858
# Runs migrations to output a dump of the database.
59-
coderd/database/dump.sql: coderd/database/dump/main.go $(wildcard coderd/database/migrations/*.sql)
60-
go run coderd/database/dump/main.go
59+
coderd/database/dump.sql: coderd/database/gen/dump/main.go $(wildcard coderd/database/migrations/*.sql)
60+
go run coderd/database/gen/dump/main.go
6161

6262
# Generates Go code for querying the database.
63-
coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
63+
coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql) coderd/database/gen/enum/main.go
6464
coderd/database/generate.sh
6565

6666
fmt/prettier:

cli/agent.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"cdr.dev/slog/sloggers/sloghuman"
2121
"github.com/coder/coder/agent"
2222
"github.com/coder/coder/agent/reaper"
23+
"github.com/coder/coder/buildinfo"
2324
"github.com/coder/coder/cli/cliflag"
2425
"github.com/coder/coder/codersdk"
2526
"github.com/coder/retry"
@@ -73,7 +74,12 @@ func workspaceAgent() *cobra.Command {
7374
return nil
7475
}
7576

76-
logger.Info(cmd.Context(), "starting agent", slog.F("url", coderURL), slog.F("auth", auth))
77+
version := buildinfo.Version()
78+
logger.Info(cmd.Context(), "starting agent",
79+
slog.F("url", coderURL),
80+
slog.F("auth", auth),
81+
slog.F("version", version),
82+
)
7783
client := codersdk.New(coderURL)
7884

7985
if pprofEnabled {
@@ -172,6 +178,10 @@ func workspaceAgent() *cobra.Command {
172178
return xerrors.Errorf("add executable to $PATH: %w", err)
173179
}
174180

181+
if err := client.PostWorkspaceAgentVersion(cmd.Context(), version); err != nil {
182+
logger.Error(cmd.Context(), "post agent version: %w", slog.Error(err), slog.F("version", version))
183+
}
184+
175185
closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{
176186
Logger: logger,
177187
EnvironmentVariables: map[string]string{

cli/agent_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"testing"
66

7+
"github.com/stretchr/testify/assert"
78
"github.com/stretchr/testify/require"
89

910
"github.com/coder/coder/cli/clitest"
@@ -59,6 +60,9 @@ func TestWorkspaceAgent(t *testing.T) {
5960
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
6061
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
6162
require.NoError(t, err)
63+
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
64+
assert.NotEmpty(t, resources[0].Agents[0].Version)
65+
}
6266
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
6367
require.NoError(t, err)
6468
defer dialer.Close()
@@ -114,6 +118,9 @@ func TestWorkspaceAgent(t *testing.T) {
114118
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
115119
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
116120
require.NoError(t, err)
121+
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
122+
assert.NotEmpty(t, resources[0].Agents[0].Version)
123+
}
117124
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
118125
require.NoError(t, err)
119126
defer dialer.Close()
@@ -169,6 +176,9 @@ func TestWorkspaceAgent(t *testing.T) {
169176
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
170177
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
171178
require.NoError(t, err)
179+
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
180+
assert.NotEmpty(t, resources[0].Agents[0].Version)
181+
}
172182
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
173183
require.NoError(t, err)
174184
defer dialer.Close()

cli/cliui/resources.go

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strconv"
88

99
"github.com/jedib0t/go-pretty/v6/table"
10+
"golang.org/x/mod/semver"
1011

1112
"github.com/coder/coder/coderd/database"
1213

@@ -18,6 +19,7 @@ type WorkspaceResourcesOptions struct {
1819
HideAgentState bool
1920
HideAccess bool
2021
Title string
22+
ServerVersion string
2123
}
2224

2325
// WorkspaceResources displays the connection status and tree-view of provided resources.
@@ -48,6 +50,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
4850
row := table.Row{"Resource"}
4951
if !options.HideAgentState {
5052
row = append(row, "Status")
53+
row = append(row, "Version")
5154
}
5255
if !options.HideAccess {
5356
row = append(row, "Access")
@@ -91,21 +94,12 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
9194
}
9295
if !options.HideAgentState {
9396
var agentStatus string
97+
var agentVersion string
9498
if !options.HideAgentState {
95-
switch agent.Status {
96-
case codersdk.WorkspaceAgentConnecting:
97-
since := database.Now().Sub(agent.CreatedAt)
98-
agentStatus = Styles.Warn.Render("⦾ connecting") + " " +
99-
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
100-
case codersdk.WorkspaceAgentDisconnected:
101-
since := database.Now().Sub(*agent.DisconnectedAt)
102-
agentStatus = Styles.Error.Render("⦾ disconnected") + " " +
103-
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
104-
case codersdk.WorkspaceAgentConnected:
105-
agentStatus = Styles.Keyword.Render("⦿ connected")
106-
}
99+
agentStatus = renderAgentStatus(agent)
100+
agentVersion = renderAgentVersion(agent.Version, options.ServerVersion)
107101
}
108-
row = append(row, agentStatus)
102+
row = append(row, agentStatus, agentVersion)
109103
}
110104
if !options.HideAccess {
111105
sshCommand := "coder ssh " + options.WorkspaceName
@@ -122,3 +116,34 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
122116
_, err := fmt.Fprintln(writer, tableWriter.Render())
123117
return err
124118
}
119+
120+
func renderAgentStatus(agent codersdk.WorkspaceAgent) string {
121+
switch agent.Status {
122+
case codersdk.WorkspaceAgentConnecting:
123+
since := database.Now().Sub(agent.CreatedAt)
124+
return Styles.Warn.Render("⦾ connecting") + " " +
125+
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
126+
case codersdk.WorkspaceAgentDisconnected:
127+
since := database.Now().Sub(*agent.DisconnectedAt)
128+
return Styles.Error.Render("⦾ disconnected") + " " +
129+
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
130+
case codersdk.WorkspaceAgentConnected:
131+
return Styles.Keyword.Render("⦿ connected")
132+
default:
133+
return Styles.Warn.Render("○ unknown")
134+
}
135+
}
136+
137+
func renderAgentVersion(agentVersion, serverVersion string) string {
138+
if agentVersion == "" {
139+
agentVersion = "(unknown)"
140+
}
141+
if !semver.IsValid(serverVersion) || !semver.IsValid(agentVersion) {
142+
return Styles.Placeholder.Render(agentVersion)
143+
}
144+
outdated := semver.Compare(agentVersion, serverVersion) < 0
145+
if outdated {
146+
return Styles.Warn.Render(agentVersion + " (outdated)")
147+
}
148+
return Styles.Keyword.Render(agentVersion)
149+
}

cli/cliui/resources_internal_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package cliui
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestRenderAgentVersion(t *testing.T) {
10+
t.Parallel()
11+
testCases := []struct {
12+
name string
13+
agentVersion string
14+
serverVersion string
15+
expected string
16+
}{
17+
{
18+
name: "OK",
19+
agentVersion: "v1.2.3",
20+
serverVersion: "v1.2.3",
21+
expected: "v1.2.3",
22+
},
23+
{
24+
name: "Outdated",
25+
agentVersion: "v1.2.3",
26+
serverVersion: "v1.2.4",
27+
expected: "v1.2.3 (outdated)",
28+
},
29+
{
30+
name: "AgentUnknown",
31+
agentVersion: "",
32+
serverVersion: "v1.2.4",
33+
expected: "(unknown)",
34+
},
35+
{
36+
name: "ServerUnknown",
37+
agentVersion: "v1.2.3",
38+
serverVersion: "",
39+
expected: "v1.2.3",
40+
},
41+
}
42+
for _, testCase := range testCases {
43+
testCase := testCase
44+
t.Run(testCase.name, func(t *testing.T) {
45+
t.Parallel()
46+
actual := renderAgentVersion(testCase.agentVersion, testCase.serverVersion)
47+
assert.Equal(t, testCase.expected, actual)
48+
})
49+
}
50+
}

cli/configssh.go

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,20 @@ func configSSH() *cobra.Command {
170170
// that it's possible to capture the diff.
171171
out = cmd.OutOrStderr()
172172
}
173-
binaryFile, err := currentBinPath(out)
173+
coderBinary, err := currentBinPath(out)
174174
if err != nil {
175175
return err
176176
}
177+
escapedCoderBinary, err := sshConfigExecEscape(coderBinary)
178+
if err != nil {
179+
return xerrors.Errorf("escape coder binary for ssh failed: %w", err)
180+
}
181+
182+
root := createConfig(cmd)
183+
escapedGlobalConfig, err := sshConfigExecEscape(string(root))
184+
if err != nil {
185+
return xerrors.Errorf("escape global config for ssh failed: %w", err)
186+
}
177187

178188
homedir, err := os.UserHomeDir()
179189
if err != nil {
@@ -238,7 +248,6 @@ func configSSH() *cobra.Command {
238248
}
239249

240250
configModified := configRaw
241-
root := createConfig(cmd)
242251

243252
buf := &bytes.Buffer{}
244253
before, after := sshConfigSplitOnCoderSection(configModified)
@@ -280,11 +289,17 @@ func configSSH() *cobra.Command {
280289
"\tLogLevel ERROR",
281290
)
282291
if !skipProxyCommand {
283-
if !wireguard {
284-
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname))
285-
} else {
286-
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --wireguard --stdio %s", binaryFile, root, hostname))
292+
wgArg := ""
293+
if wireguard {
294+
wgArg = "--wireguard "
287295
}
296+
configOptions = append(
297+
configOptions,
298+
fmt.Sprintf(
299+
"\tProxyCommand %s --global-config %s ssh %s--stdio %s",
300+
escapedCoderBinary, escapedGlobalConfig, wgArg, hostname,
301+
),
302+
)
288303
}
289304

290305
_, _ = buf.WriteString(strings.Join(configOptions, "\n"))
@@ -451,6 +466,11 @@ func writeWithTempFileAndMove(path string, r io.Reader) (err error) {
451466
dir := filepath.Dir(path)
452467
name := filepath.Base(path)
453468

469+
// Ensure that e.g. the ~/.ssh directory exists.
470+
if err = os.MkdirAll(dir, 0o700); err != nil {
471+
return xerrors.Errorf("create directory: %w", err)
472+
}
473+
454474
// Create a tempfile in the same directory for ensuring write
455475
// operation does not fail.
456476
f, err := os.CreateTemp(dir, fmt.Sprintf(".%s.", name))
@@ -482,6 +502,52 @@ func writeWithTempFileAndMove(path string, r io.Reader) (err error) {
482502
return nil
483503
}
484504

505+
// sshConfigExecEscape quotes the string if it contains spaces, as per
506+
// `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to
507+
// run the command, and as such the formatting/escape requirements
508+
// cannot simply be covered by `fmt.Sprintf("%q", path)`.
509+
//
510+
// Always escaping the path with `fmt.Sprintf("%q", path)` usually works
511+
// on most platforms, but double quotes sometimes break on Windows 10
512+
// (see #2853). This function takes a best-effort approach to improving
513+
// compatibility and covering edge cases.
514+
//
515+
// Given the following ProxyCommand:
516+
//
517+
// ProxyCommand "/path/with space/coder" ssh --stdio work
518+
//
519+
// This is ~what OpenSSH would execute:
520+
//
521+
// /bin/bash -c '"/path/with space/to/coder" ssh --stdio workspace'
522+
//
523+
// However, since it's actually an arg in C, the contents inside the
524+
// single quotes are interpreted as is, e.g. if there was a '\t', it
525+
// would be the literal string '\t', not a tab.
526+
//
527+
// See:
528+
// - https://github.com/coder/coder/issues/2853
529+
// - https://github.com/openssh/openssh-portable/blob/V_9_0_P1/sshconnect.c#L158-L167
530+
// - https://github.com/PowerShell/openssh-portable/blob/v8.1.0.0/sshconnect.c#L231-L293
531+
// - https://github.com/PowerShell/openssh-portable/blob/v8.1.0.0/contrib/win32/win32compat/w32fd.c#L1075-L1100
532+
func sshConfigExecEscape(path string) (string, error) {
533+
// This is unlikely to ever happen, but newlines are allowed on
534+
// certain filesystems, but cannot be used inside ssh config.
535+
if strings.ContainsAny(path, "\n") {
536+
return "", xerrors.Errorf("invalid path: %s", path)
537+
}
538+
// In the unlikely even that a path contains quotes, they must be
539+
// escaped so that they are not interpreted as shell quotes.
540+
if strings.Contains(path, "\"") {
541+
path = strings.ReplaceAll(path, "\"", "\\\"")
542+
}
543+
// A space or a tab requires quoting, but tabs must not be escaped
544+
// (\t) since OpenSSH interprets it as a literal \t, not a tab.
545+
if strings.ContainsAny(path, " \t") {
546+
path = fmt.Sprintf("\"%s\"", path) //nolint:gocritic // We don't want %q here.
547+
}
548+
return path, nil
549+
}
550+
485551
// currentBinPath returns the path to the coder binary suitable for use in ssh
486552
// ProxyCommand.
487553
func currentBinPath(w io.Writer) (string, error) {

0 commit comments

Comments
 (0)