From 5362f4636ef9b588c71d99d2bee5bd942207ddce Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 31 Aug 2022 16:33:50 +0100 Subject: [PATCH] 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. --- cli/agent.go | 12 ++++- cli/agent_test.go | 10 ++++ cli/cliui/resources.go | 51 ++++++++++++++----- cli/cliui/resources_internal_test.go | 50 ++++++++++++++++++ cli/show.go | 5 ++ coderd/coderd.go | 1 + coderd/coderdtest/authtest.go | 1 + coderd/database/databasefake/databasefake.go | 16 ++++++ coderd/database/dump.sql | 5 +- ...00039_add_workspace_agent_version.down.sql | 1 + .../000039_add_workspace_agent_version.up.sql | 2 + coderd/database/models.go | 2 + coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 37 +++++++++++--- coderd/database/queries/workspaceagents.sql | 8 +++ coderd/workspaceagents.go | 42 +++++++++++++++ codersdk/workspaceagents.go | 19 +++++++ codersdk/workspaceresources.go | 1 + site/package.json | 2 + site/src/api/typesGenerated.ts | 6 +++ site/src/components/Resources/Resources.tsx | 22 +++++++- .../Tooltips/AgentOutdatedTooltip.tsx | 23 +++++++++ .../Workspace/Workspace.stories.tsx | 1 + site/src/components/Workspace/Workspace.tsx | 3 ++ .../WorkspacePage/WorkspacePage.test.tsx | 2 +- .../src/pages/WorkspacePage/WorkspacePage.tsx | 4 +- site/src/testHelpers/entities.ts | 12 ++++- site/src/util/workspace.test.ts | 20 ++++++++ site/src/util/workspace.ts | 28 ++++++++++ site/yarn.lock | 5 ++ 30 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 cli/cliui/resources_internal_test.go create mode 100644 coderd/database/migrations/000039_add_workspace_agent_version.down.sql create mode 100644 coderd/database/migrations/000039_add_workspace_agent_version.up.sql create mode 100644 site/src/components/Tooltips/AgentOutdatedTooltip.tsx diff --git a/cli/agent.go b/cli/agent.go index 38d94d6bf7..24571e9fbc 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -20,6 +20,7 @@ import ( "cdr.dev/slog/sloggers/sloghuman" "github.com/coder/coder/agent" "github.com/coder/coder/agent/reaper" + "github.com/coder/coder/buildinfo" "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/codersdk" "github.com/coder/retry" @@ -73,7 +74,12 @@ func workspaceAgent() *cobra.Command { return nil } - logger.Info(cmd.Context(), "starting agent", slog.F("url", coderURL), slog.F("auth", auth)) + version := buildinfo.Version() + logger.Info(cmd.Context(), "starting agent", + slog.F("url", coderURL), + slog.F("auth", auth), + slog.F("version", version), + ) client := codersdk.New(coderURL) if pprofEnabled { @@ -172,6 +178,10 @@ func workspaceAgent() *cobra.Command { return xerrors.Errorf("add executable to $PATH: %w", err) } + if err := client.PostWorkspaceAgentVersion(cmd.Context(), version); err != nil { + logger.Error(cmd.Context(), "post agent version: %w", slog.Error(err), slog.F("version", version)) + } + closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{ Logger: logger, EnvironmentVariables: map[string]string{ diff --git a/cli/agent_test.go b/cli/agent_test.go index 8ba6a07242..3dbd924312 100644 --- a/cli/agent_test.go +++ b/cli/agent_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/cli/clitest" @@ -59,6 +60,9 @@ func TestWorkspaceAgent(t *testing.T) { coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) + if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) { + assert.NotEmpty(t, resources[0].Agents[0].Version) + } dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer dialer.Close() @@ -114,6 +118,9 @@ func TestWorkspaceAgent(t *testing.T) { coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) + if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) { + assert.NotEmpty(t, resources[0].Agents[0].Version) + } dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer dialer.Close() @@ -169,6 +176,9 @@ func TestWorkspaceAgent(t *testing.T) { coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) + if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) { + assert.NotEmpty(t, resources[0].Agents[0].Version) + } dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer dialer.Close() diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index b9ce239e45..cbda8134d8 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/jedib0t/go-pretty/v6/table" + "golang.org/x/mod/semver" "github.com/coder/coder/coderd/database" @@ -18,6 +19,7 @@ type WorkspaceResourcesOptions struct { HideAgentState bool HideAccess bool Title string + ServerVersion string } // WorkspaceResources displays the connection status and tree-view of provided resources. @@ -48,6 +50,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource row := table.Row{"Resource"} if !options.HideAgentState { row = append(row, "Status") + row = append(row, "Version") } if !options.HideAccess { row = append(row, "Access") @@ -91,21 +94,12 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource } if !options.HideAgentState { var agentStatus string + var agentVersion string if !options.HideAgentState { - switch agent.Status { - case codersdk.WorkspaceAgentConnecting: - since := database.Now().Sub(agent.CreatedAt) - agentStatus = Styles.Warn.Render("⦾ connecting") + " " + - Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]") - case codersdk.WorkspaceAgentDisconnected: - since := database.Now().Sub(*agent.DisconnectedAt) - agentStatus = Styles.Error.Render("⦾ disconnected") + " " + - Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]") - case codersdk.WorkspaceAgentConnected: - agentStatus = Styles.Keyword.Render("⦿ connected") - } + agentStatus = renderAgentStatus(agent) + agentVersion = renderAgentVersion(agent.Version, options.ServerVersion) } - row = append(row, agentStatus) + row = append(row, agentStatus, agentVersion) } if !options.HideAccess { sshCommand := "coder ssh " + options.WorkspaceName @@ -122,3 +116,34 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource _, err := fmt.Fprintln(writer, tableWriter.Render()) return err } + +func renderAgentStatus(agent codersdk.WorkspaceAgent) string { + switch agent.Status { + case codersdk.WorkspaceAgentConnecting: + since := database.Now().Sub(agent.CreatedAt) + return Styles.Warn.Render("⦾ connecting") + " " + + Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]") + case codersdk.WorkspaceAgentDisconnected: + since := database.Now().Sub(*agent.DisconnectedAt) + return Styles.Error.Render("⦾ disconnected") + " " + + Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]") + case codersdk.WorkspaceAgentConnected: + return Styles.Keyword.Render("⦿ connected") + default: + return Styles.Warn.Render("○ unknown") + } +} + +func renderAgentVersion(agentVersion, serverVersion string) string { + if agentVersion == "" { + agentVersion = "(unknown)" + } + if !semver.IsValid(serverVersion) || !semver.IsValid(agentVersion) { + return Styles.Placeholder.Render(agentVersion) + } + outdated := semver.Compare(agentVersion, serverVersion) < 0 + if outdated { + return Styles.Warn.Render(agentVersion + " (outdated)") + } + return Styles.Keyword.Render(agentVersion) +} diff --git a/cli/cliui/resources_internal_test.go b/cli/cliui/resources_internal_test.go new file mode 100644 index 0000000000..21212f8873 --- /dev/null +++ b/cli/cliui/resources_internal_test.go @@ -0,0 +1,50 @@ +package cliui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRenderAgentVersion(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + agentVersion string + serverVersion string + expected string + }{ + { + name: "OK", + agentVersion: "v1.2.3", + serverVersion: "v1.2.3", + expected: "v1.2.3", + }, + { + name: "Outdated", + agentVersion: "v1.2.3", + serverVersion: "v1.2.4", + expected: "v1.2.3 (outdated)", + }, + { + name: "AgentUnknown", + agentVersion: "", + serverVersion: "v1.2.4", + expected: "(unknown)", + }, + { + name: "ServerUnknown", + agentVersion: "v1.2.3", + serverVersion: "", + expected: "v1.2.3", + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + actual := renderAgentVersion(testCase.agentVersion, testCase.serverVersion) + assert.Equal(t, testCase.expected, actual) + }) + } +} diff --git a/cli/show.go b/cli/show.go index 3a0ed3973a..069b464645 100644 --- a/cli/show.go +++ b/cli/show.go @@ -18,6 +18,10 @@ func show() *cobra.Command { if err != nil { return err } + buildInfo, err := client.BuildInfo(cmd.Context()) + if err != nil { + return xerrors.Errorf("get server version: %w", err) + } workspace, err := namedWorkspace(cmd, client, args[0]) if err != nil { return xerrors.Errorf("get workspace: %w", err) @@ -28,6 +32,7 @@ func show() *cobra.Command { } return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ WorkspaceName: workspace.Name, + ServerVersion: buildInfo.Version, }) }, } diff --git a/coderd/coderd.go b/coderd/coderd.go index bbad4309d3..58a4386c90 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -333,6 +333,7 @@ func New(options *Options) *API { r.Route("/me", func(r chi.Router) { r.Use(httpmw.ExtractWorkspaceAgent(options.Database)) r.Get("/metadata", api.workspaceAgentMetadata) + r.Post("/version", api.postWorkspaceAgentVersion) r.Get("/listen", api.workspaceAgentListen) r.Get("/gitsshkey", api.agentGitSSHKey) r.Get("/turn", api.workspaceAgentTurn) diff --git a/coderd/coderdtest/authtest.go b/coderd/coderdtest/authtest.go index f88fd21f20..0ca2d1a088 100644 --- a/coderd/coderdtest/authtest.go +++ b/coderd/coderdtest/authtest.go @@ -194,6 +194,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { "GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/derp": {NoAuthorize: true}, + "POST:/api/v2/workspaceagents/me/version": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/wireguardlisten": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/me/keys": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true}, diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 2b42d0cca7..34c81fc046 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2047,6 +2047,22 @@ func (q *fakeQuerier) UpdateWorkspaceAgentKeysByID(_ context.Context, arg databa return sql.ErrNoRows } +func (q *fakeQuerier) UpdateWorkspaceAgentVersionByID(_ context.Context, arg database.UpdateWorkspaceAgentVersionByIDParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, agent := range q.provisionerJobAgents { + if agent.ID != arg.ID { + continue + } + + agent.Version = arg.Version + q.provisionerJobAgents[index] = agent + return nil + } + return sql.ErrNoRows +} + func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.UpdateProvisionerJobByIDParams) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 9e28ed5bc4..c004242bd5 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -308,9 +308,12 @@ CREATE TABLE workspace_agents ( directory character varying(4096) DEFAULT ''::character varying NOT NULL, wireguard_node_ipv6 inet DEFAULT '::'::inet NOT NULL, wireguard_node_public_key character varying(128) DEFAULT 'nodekey:0000000000000000000000000000000000000000000000000000000000000000'::character varying NOT NULL, - wireguard_disco_public_key character varying(128) DEFAULT 'discokey:0000000000000000000000000000000000000000000000000000000000000000'::character varying NOT NULL + wireguard_disco_public_key character varying(128) DEFAULT 'discokey:0000000000000000000000000000000000000000000000000000000000000000'::character varying NOT NULL, + version text DEFAULT ''::text NOT NULL ); +COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.'; + CREATE TABLE workspace_apps ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, diff --git a/coderd/database/migrations/000039_add_workspace_agent_version.down.sql b/coderd/database/migrations/000039_add_workspace_agent_version.down.sql new file mode 100644 index 0000000000..932ff618b8 --- /dev/null +++ b/coderd/database/migrations/000039_add_workspace_agent_version.down.sql @@ -0,0 +1 @@ +ALTER TABLE ONLY workspace_agents DROP COLUMN version; diff --git a/coderd/database/migrations/000039_add_workspace_agent_version.up.sql b/coderd/database/migrations/000039_add_workspace_agent_version.up.sql new file mode 100644 index 0000000000..52e32f9f5a --- /dev/null +++ b/coderd/database/migrations/000039_add_workspace_agent_version.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY workspace_agents ADD COLUMN version text DEFAULT ''::text NOT NULL; +COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index f9f559711e..4139e9b16b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -536,6 +536,8 @@ type WorkspaceAgent struct { WireguardNodeIPv6 pqtype.Inet `db:"wireguard_node_ipv6" json:"wireguard_node_ipv6"` WireguardNodePublicKey dbtypes.NodePublic `db:"wireguard_node_public_key" json:"wireguard_node_public_key"` WireguardDiscoPublicKey dbtypes.DiscoPublic `db:"wireguard_disco_public_key" json:"wireguard_disco_public_key"` + // Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start. + Version string `db:"version" json:"version"` } type WorkspaceApp struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 389f15b385..3644d2d969 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -144,6 +144,7 @@ type querier interface { UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAgentKeysByID(ctx context.Context, arg UpdateWorkspaceAgentKeysByIDParams) error + UpdateWorkspaceAgentVersionByID(ctx context.Context, arg UpdateWorkspaceAgentVersionByIDParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1e4a194fa7..a092c5def9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3158,7 +3158,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version FROM workspace_agents WHERE @@ -3191,13 +3191,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken &i.WireguardNodeIPv6, &i.WireguardNodePublicKey, &i.WireguardDiscoPublicKey, + &i.Version, ) return i, err } const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version FROM workspace_agents WHERE @@ -3228,13 +3229,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (W &i.WireguardNodeIPv6, &i.WireguardNodePublicKey, &i.WireguardDiscoPublicKey, + &i.Version, ) return i, err } const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version FROM workspace_agents WHERE @@ -3267,13 +3269,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst &i.WireguardNodeIPv6, &i.WireguardNodePublicKey, &i.WireguardDiscoPublicKey, + &i.Version, ) return i, err } const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version FROM workspace_agents WHERE @@ -3310,6 +3313,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] &i.WireguardNodeIPv6, &i.WireguardNodePublicKey, &i.WireguardDiscoPublicKey, + &i.Version, ); err != nil { return nil, err } @@ -3325,7 +3329,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] } const getWorkspaceAgentsCreatedAfter = `-- name: GetWorkspaceAgentsCreatedAfter :many -SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key FROM workspace_agents WHERE created_at > $1 +SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version FROM workspace_agents WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) { @@ -3358,6 +3362,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created &i.WireguardNodeIPv6, &i.WireguardNodePublicKey, &i.WireguardDiscoPublicKey, + &i.Version, ); err != nil { return nil, err } @@ -3394,7 +3399,7 @@ INSERT INTO wireguard_disco_public_key ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version ` type InsertWorkspaceAgentParams struct { @@ -3459,6 +3464,7 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa &i.WireguardNodeIPv6, &i.WireguardNodePublicKey, &i.WireguardDiscoPublicKey, + &i.Version, ) return i, err } @@ -3522,6 +3528,25 @@ func (q *sqlQuerier) UpdateWorkspaceAgentKeysByID(ctx context.Context, arg Updat return err } +const updateWorkspaceAgentVersionByID = `-- name: UpdateWorkspaceAgentVersionByID :exec +UPDATE + workspace_agents +SET + version = $2 +WHERE + id = $1 +` + +type UpdateWorkspaceAgentVersionByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + Version string `db:"version" json:"version"` +} + +func (q *sqlQuerier) UpdateWorkspaceAgentVersionByID(ctx context.Context, arg UpdateWorkspaceAgentVersionByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceAgentVersionByID, arg.ID, arg.Version) + return err +} + const getWorkspaceAppByAgentIDAndName = `-- name: GetWorkspaceAppByAgentIDAndName :one SELECT id, created_at, agent_id, name, icon, command, url, relative_path FROM workspace_apps WHERE agent_id = $1 AND name = $2 ` diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index 4a34a34e54..ca136a759e 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -81,3 +81,11 @@ SET updated_at = $4 WHERE id = $1; + +-- name: UpdateWorkspaceAgentVersionByID :exec +UPDATE + workspace_agents +SET + version = $2 +WHERE + id = $1; diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index efac7f8bc3..e4e57488c9 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -14,6 +14,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/yamux" "github.com/tabbed/pqtype" + "golang.org/x/mod/semver" "golang.org/x/xerrors" "inet.af/netaddr" "nhooyr.io/websocket" @@ -152,6 +153,46 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) }) } +func (api *API) postWorkspaceAgentVersion(rw http.ResponseWriter, r *http.Request) { + workspaceAgent := httpmw.WorkspaceAgent(r) + apiAgent, err := convertWorkspaceAgent(workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error reading workspace agent.", + Detail: err.Error(), + }) + return + } + + var req codersdk.PostWorkspaceAgentVersionRequest + if !httpapi.Read(rw, r, &req) { + return + } + + api.Logger.Info(r.Context(), "post workspace agent version", slog.F("agent_id", apiAgent.ID), slog.F("agent_version", req.Version)) + + if !semver.IsValid(req.Version) { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid workspace agent version provided.", + Detail: fmt.Sprintf("invalid semver version: %q", req.Version), + }) + return + } + + if err := api.Database.UpdateWorkspaceAgentVersionByID(r.Context(), database.UpdateWorkspaceAgentVersionByIDParams{ + ID: apiAgent.ID, + Version: req.Version, + }); err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Error setting agent version", + Detail: err.Error(), + }) + return + } + + httpapi.Write(rw, http.StatusOK, nil) +} + func (api *API) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) { api.websocketWaitMutex.Lock() api.websocketWaitGroup.Add(1) @@ -707,6 +748,7 @@ func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, apps []codersdk.Work IPv6: inetToNetaddr(dbAgent.WireguardNodeIPv6), WireguardPublicKey: key.NodePublic(dbAgent.WireguardNodePublicKey), DiscoPublicKey: key.DiscoPublic(dbAgent.WireguardDiscoPublicKey), + Version: dbAgent.Version, } if dbAgent.FirstConnectedAt.Valid { diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 2ee7973c22..b2aeff66ae 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -48,6 +48,10 @@ type WorkspaceAgentAuthenticateResponse struct { SessionToken string `json:"session_token"` } +type PostWorkspaceAgentVersionRequest struct { + Version string `json:"version"` +} + // AuthWorkspaceGoogleInstanceIdentity uses the Google Compute Engine Metadata API to // fetch a signed JWT, and exchange it for a session token for a workspace agent. // @@ -240,6 +244,8 @@ func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) ( if err != nil { return agent.Metadata{}, nil, xerrors.Errorf("listen peerbroker: %w", err) } + + // Fetch updated agent metadata res, err = c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil) if err != nil { return agent.Metadata{}, nil, err @@ -429,6 +435,19 @@ func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAge return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent) } +func (c *Client) PostWorkspaceAgentVersion(ctx context.Context, version string) error { + // Phone home and tell the mothership what version we're on. + versionReq := PostWorkspaceAgentVersionRequest{Version: version} + res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/version", versionReq) + if err != nil { + return readBodyAsError(res) + } + // Discord the response + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + return nil +} + // WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided. // It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON. // Responses are PTY output that can be rendered. diff --git a/codersdk/workspaceresources.go b/codersdk/workspaceresources.go index 8c4d9e2c2e..cd9b7cff1c 100644 --- a/codersdk/workspaceresources.go +++ b/codersdk/workspaceresources.go @@ -57,6 +57,7 @@ type WorkspaceAgent struct { WireguardPublicKey key.NodePublic `json:"wireguard_public_key"` DiscoPublicKey key.DiscoPublic `json:"disco_public_key"` IPv6 netaddr.IPPrefix `json:"ipv6"` + Version string `json:"version"` } type WorkspaceAgentResourceMetadata struct { diff --git a/site/package.json b/site/package.json index 616f797e70..884e82d40f 100644 --- a/site/package.json +++ b/site/package.json @@ -81,6 +81,7 @@ "@types/react": "18.0.15", "@types/react-dom": "18.0.6", "@types/react-helmet": "6.1.5", + "@types/semver": "^7.3.12", "@types/superagent": "4.1.15", "@types/uuid": "8.3.4", "@typescript-eslint/eslint-plugin": "5.31.0", @@ -112,6 +113,7 @@ "prettier": "2.7.1", "prettier-plugin-organize-imports": "3.0.0", "react-hot-loader": "4.13.0", + "semver": "^7.3.7", "sql-formatter": "8.2.0", "style-loader": "3.3.1", "ts-jest": "27.1.4", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a399774d10..14f1d5abb8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -249,6 +249,11 @@ export interface ParameterSchema { readonly validation_contains?: string[] } +// From codersdk/workspaceagents.go +export interface PostWorkspaceAgentVersionRequest { + readonly version: string +} + // From codersdk/provisionerdaemons.go export interface ProvisionerDaemon { readonly id: string @@ -479,6 +484,7 @@ export interface WorkspaceAgent { // Named type "inet.af/netaddr.IPPrefix" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly ipv6: any + readonly version: string } // From codersdk/workspaceagents.go diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index 1b1e7db516..97b63e519e 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -9,14 +9,15 @@ import useTheme from "@material-ui/styles/useTheme" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" import { TableCellDataPrimary } from "components/TableCellData/TableCellData" import { FC } from "react" -import { getDisplayAgentStatus } from "util/workspace" -import { Workspace, WorkspaceResource } from "../../api/typesGenerated" +import { getDisplayAgentStatus, getDisplayVersionStatus } from "util/workspace" +import { BuildInfoResponse, Workspace, WorkspaceResource } from "../../api/typesGenerated" import { AppLink } from "../AppLink/AppLink" import { SSHButton } from "../SSHButton/SSHButton" import { Stack } from "../Stack/Stack" import { TableHeaderRow } from "../TableHeaders/TableHeaders" import { TerminalLink } from "../TerminalLink/TerminalLink" import { AgentHelpTooltip } from "../Tooltips/AgentHelpTooltip" +import { AgentOutdatedTooltip } from "../Tooltips/AgentOutdatedTooltip" import { ResourcesHelpTooltip } from "../Tooltips/ResourcesHelpTooltip" import { ResourceAvatarData } from "./ResourceAvatarData" @@ -26,6 +27,7 @@ const Language = { agentsLabel: "Agents", agentLabel: "Agent", statusLabel: "status: ", + versionLabel: "version: ", osLabel: "os: ", } @@ -34,6 +36,7 @@ interface ResourcesProps { getResourcesError?: Error | unknown workspace: Workspace canUpdateWorkspace: boolean + buildInfo?: BuildInfoResponse | undefined } export const Resources: FC> = ({ @@ -41,9 +44,11 @@ export const Resources: FC> = ({ getResourcesError, workspace, canUpdateWorkspace, + buildInfo, }) => { const styles = useStyles() const theme: Theme = useTheme() + const serverVersion = buildInfo?.version || "" return (
@@ -90,6 +95,10 @@ export const Resources: FC> = ({ ) } + const { displayVersion, outdated } = getDisplayVersionStatus( + agent.version, + serverVersion, + ) const agentStatus = getDisplayAgentStatus(theme, agent) return ( @@ -114,6 +123,11 @@ export const Resources: FC> = ({ {Language.osLabel} {agent.operating_system}
+
+ {Language.versionLabel} + {displayVersion} + +
@@ -188,6 +202,10 @@ const useStyles = makeStyles((theme) => ({ textTransform: "capitalize", }, + agentVersion: { + display: "block", + }, + accessLinks: { display: "flex", gap: theme.spacing(0.5), diff --git a/site/src/components/Tooltips/AgentOutdatedTooltip.tsx b/site/src/components/Tooltips/AgentOutdatedTooltip.tsx new file mode 100644 index 0000000000..1c8f31235c --- /dev/null +++ b/site/src/components/Tooltips/AgentOutdatedTooltip.tsx @@ -0,0 +1,23 @@ +import { FC } from "react" +import { HelpTooltip, HelpTooltipText, HelpTooltipTitle } from "./HelpTooltip" + +export const Language = { + label: "Agent Outdated", + text: "This agent is an older version than the Coder server. This can happen after you update Coder with running workspaces. To fix this, you can stop and start the workspace.", +} + +interface TooltipProps { + outdated: boolean +} + +export const AgentOutdatedTooltip: FC> = ({ outdated }) => { + if (!outdated) { + return null + } + return ( + + {Language.label} + {Language.text} + + ) +} diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 7dea59d97c..2fd6136e60 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -44,6 +44,7 @@ Started.args = { builds: [Mocks.MockWorkspaceBuild], canUpdateWorkspace: true, workspaceErrors: {}, + buildInfo: Mocks.MockBuildInfo, } export const WithoutUpdateAccess = Template.bind({}) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 0d74eca52d..62099bdb9b 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -44,6 +44,7 @@ export interface WorkspaceProps { builds?: TypesGen.WorkspaceBuild[] canUpdateWorkspace: boolean workspaceErrors: Partial> + buildInfo?: TypesGen.BuildInfoResponse } /** @@ -62,6 +63,7 @@ export const Workspace: FC> = ({ builds, canUpdateWorkspace, workspaceErrors, + buildInfo, }) => { const styles = useStyles() const navigate = useNavigate() @@ -128,6 +130,7 @@ export const Workspace: FC> = ({ getResourcesError={workspaceErrors[WorkspaceErrors.GET_RESOURCES_ERROR]} workspace={workspace} canUpdateWorkspace={canUpdateWorkspace} + buildInfo={buildInfo} /> )} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 326fa0205f..b341d3255d 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -204,7 +204,7 @@ describe("WorkspacePage", () => { const agent1Status = await screen.findAllByText( DisplayAgentStatusLanguage[MockWorkspaceAgent.status], ) - expect(agent1Status.length).toEqual(2) + expect(agent1Status.length).toEqual(4) const agent2Status = await screen.findAllByText( DisplayAgentStatusLanguage[MockWorkspaceAgentDisconnected.status], ) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 17e6f197da..f7209d6831 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,5 +1,5 @@ import { makeStyles } from "@material-ui/core/styles" -import { useMachine, useSelector } from "@xstate/react" +import { useActor, useMachine, useSelector } from "@xstate/react" import dayjs from "dayjs" import minMax from "dayjs/plugin/minMax" import { FC, useContext, useEffect } from "react" @@ -51,6 +51,7 @@ export const WorkspacePage: FC = () => { const canUpdateWorkspace = !!permissions?.updateWorkspace const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine) + const [buildInfoState] = useActor(xServices.buildInfoXService) const styles = useStyles() @@ -133,6 +134,7 @@ export const WorkspacePage: FC = () => { [WorkspaceErrors.BUILD_ERROR]: buildError, [WorkspaceErrors.CANCELLATION_ERROR]: cancellationError, }} + buildInfo={buildInfoState.context.buildInfo} /> workspace", () => { expect(getDisplayWorkspaceBuildInitiatedBy(build)).toEqual(initiatedBy) }) }) + + describe("getDisplayVersionStatus", () => { + it.each<[string, string, string, boolean]>([ + ["", "", "(unknown)", false], + ["", "v1.2.3", "(unknown)", false], + ["v1.2.3", "", "v1.2.3", false], + ["v1.2.3", "v1.2.3", "v1.2.3", false], + ["v1.2.3", "v1.2.4", "v1.2.3 (outdated)", true], + ["v1.2.4", "v1.2.3", "v1.2.4", false], + ["foo", "bar", "foo", false], + ])( + `getDisplayVersionStatus(theme, %p, %p) returns (%p, %p)`, + (agentVersion, serverVersion, expectedVersion, expectedOutdated) => { + const { displayVersion, outdated } = getDisplayVersionStatus(agentVersion, serverVersion) + expect(displayVersion).toEqual(expectedVersion) + expect(expectedOutdated).toEqual(outdated) + }, + ) + }) }) diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index fec1c7b4de..65b6b21ee1 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -3,6 +3,7 @@ import dayjs from "dayjs" import duration from "dayjs/plugin/duration" import minMax from "dayjs/plugin/minMax" import utc from "dayjs/plugin/utc" +import semver from "semver" import { WorkspaceBuildTransition } from "../api/types" import * as TypesGen from "../api/typesGenerated" @@ -95,6 +96,11 @@ export const DisplayWorkspaceBuildStatusLanguage = { failed: "Failed", } +export const DisplayAgentVersionLanguage = { + unknown: "unknown", + outdated: "outdated", +} + export const getDisplayWorkspaceBuildStatus = ( theme: Theme, build: TypesGen.WorkspaceBuild, @@ -211,6 +217,28 @@ export const getDisplayAgentStatus = ( } } +export const getDisplayVersionStatus = ( + agentVersion: string, + serverVersion: string, +): { displayVersion: string; outdated: boolean } => { + if (!semver.valid(serverVersion) || !semver.valid(agentVersion)) { + return { + displayVersion: `${agentVersion}` || `(${DisplayAgentVersionLanguage.unknown})`, + outdated: false, + } + } else if (semver.lt(agentVersion, serverVersion)) { + return { + displayVersion: `${agentVersion} (${DisplayAgentVersionLanguage.outdated})`, + outdated: true, + } + } else { + return { + displayVersion: agentVersion, + outdated: false, + } + } +} + export const isWorkspaceOn = (workspace: TypesGen.Workspace): boolean => { const transition = workspace.latest_build.transition const status = workspace.latest_build.job.status diff --git a/site/yarn.lock b/site/yarn.lock index bd133804ea..40bd8f790f 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -3330,6 +3330,11 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== +"@types/semver@^7.3.12": + version "7.3.12" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.12.tgz#920447fdd78d76b19de0438b7f60df3c4a80bf1c" + integrity sha512-WwA1MW0++RfXmCr12xeYOOC5baSC9mSb0ZqCquFzKhcoF4TvHu5MKOuXsncgZcpVFhB1pXd5hZmM0ryAoCp12A== + "@types/serve-index@^1.9.1": version "1.9.1" resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278"