Skip to content

Commit 5362f46

Browse files
authored
feat: show agent version in UI and CLI (#3709)
This commit adds the ability for agents to set their version upon start. This is then reported in the UI and CLI.
1 parent aa9a1c3 commit 5362f46

30 files changed

+366
-26
lines changed

cli/agent.go

+11-1
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

+10
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

+38-13
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

+50
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/show.go

+5
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ func show() *cobra.Command {
1818
if err != nil {
1919
return err
2020
}
21+
buildInfo, err := client.BuildInfo(cmd.Context())
22+
if err != nil {
23+
return xerrors.Errorf("get server version: %w", err)
24+
}
2125
workspace, err := namedWorkspace(cmd, client, args[0])
2226
if err != nil {
2327
return xerrors.Errorf("get workspace: %w", err)
@@ -28,6 +32,7 @@ func show() *cobra.Command {
2832
}
2933
return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
3034
WorkspaceName: workspace.Name,
35+
ServerVersion: buildInfo.Version,
3136
})
3237
},
3338
}

coderd/coderd.go

+1
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ func New(options *Options) *API {
333333
r.Route("/me", func(r chi.Router) {
334334
r.Use(httpmw.ExtractWorkspaceAgent(options.Database))
335335
r.Get("/metadata", api.workspaceAgentMetadata)
336+
r.Post("/version", api.postWorkspaceAgentVersion)
336337
r.Get("/listen", api.workspaceAgentListen)
337338
r.Get("/gitsshkey", api.agentGitSSHKey)
338339
r.Get("/turn", api.workspaceAgentTurn)

coderd/coderdtest/authtest.go

+1
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
194194
"GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true},
195195
"GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true},
196196
"GET:/api/v2/workspaceagents/me/derp": {NoAuthorize: true},
197+
"POST:/api/v2/workspaceagents/me/version": {NoAuthorize: true},
197198
"GET:/api/v2/workspaceagents/me/wireguardlisten": {NoAuthorize: true},
198199
"POST:/api/v2/workspaceagents/me/keys": {NoAuthorize: true},
199200
"GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true},

coderd/database/databasefake/databasefake.go

+16
Original file line numberDiff line numberDiff line change
@@ -2047,6 +2047,22 @@ func (q *fakeQuerier) UpdateWorkspaceAgentKeysByID(_ context.Context, arg databa
20472047
return sql.ErrNoRows
20482048
}
20492049

2050+
func (q *fakeQuerier) UpdateWorkspaceAgentVersionByID(_ context.Context, arg database.UpdateWorkspaceAgentVersionByIDParams) error {
2051+
q.mutex.Lock()
2052+
defer q.mutex.Unlock()
2053+
2054+
for index, agent := range q.provisionerJobAgents {
2055+
if agent.ID != arg.ID {
2056+
continue
2057+
}
2058+
2059+
agent.Version = arg.Version
2060+
q.provisionerJobAgents[index] = agent
2061+
return nil
2062+
}
2063+
return sql.ErrNoRows
2064+
}
2065+
20502066
func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.UpdateProvisionerJobByIDParams) error {
20512067
q.mutex.Lock()
20522068
defer q.mutex.Unlock()

coderd/database/dump.sql

+4-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE ONLY workspace_agents DROP COLUMN version;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE ONLY workspace_agents ADD COLUMN version text DEFAULT ''::text NOT NULL;
2+
COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.';

coderd/database/models.go

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/querier.go

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)