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.
This commit is contained in:
Cian Johnston
2022-08-31 16:33:50 +01:00
committed by GitHub
parent aa9a1c3f56
commit 5362f4636e
30 changed files with 366 additions and 26 deletions
+11 -1
View File
@@ -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{
+10
View File
@@ -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()
+38 -13
View File
@@ -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)
}
+50
View File
@@ -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)
})
}
}
+5
View File
@@ -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,
})
},
}
+1
View File
@@ -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)
+1
View File
@@ -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},
@@ -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()
+4 -1
View File
@@ -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,
@@ -0,0 +1 @@
ALTER TABLE ONLY workspace_agents DROP COLUMN version;
@@ -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.';
+2
View File
@@ -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 {
+1
View File
@@ -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
+31 -6
View File
@@ -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
`
@@ -81,3 +81,11 @@ SET
updated_at = $4
WHERE
id = $1;
-- name: UpdateWorkspaceAgentVersionByID :exec
UPDATE
workspace_agents
SET
version = $2
WHERE
id = $1;
+42
View File
@@ -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 {
+19
View File
@@ -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.
+1
View File
@@ -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 {
+2
View File
@@ -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",
+6
View File
@@ -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
+20 -2
View File
@@ -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<React.PropsWithChildren<ResourcesProps>> = ({
@@ -41,9 +44,11 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
getResourcesError,
workspace,
canUpdateWorkspace,
buildInfo,
}) => {
const styles = useStyles()
const theme: Theme = useTheme()
const serverVersion = buildInfo?.version || ""
return (
<div aria-label={Language.resources} className={styles.wrapper}>
@@ -90,6 +95,10 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
)
}
const { displayVersion, outdated } = getDisplayVersionStatus(
agent.version,
serverVersion,
)
const agentStatus = getDisplayAgentStatus(theme, agent)
return (
<TableRow key={`${resource.id}-${agent.id}`}>
@@ -114,6 +123,11 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
<strong>{Language.osLabel}</strong>
<span className={styles.operatingSystem}>{agent.operating_system}</span>
</div>
<div className={styles.dataRow}>
<strong>{Language.versionLabel}</strong>
<span className={styles.agentVersion}>{displayVersion}</span>
<AgentOutdatedTooltip outdated={outdated} />
</div>
</div>
</TableCell>
<TableCell>
@@ -188,6 +202,10 @@ const useStyles = makeStyles((theme) => ({
textTransform: "capitalize",
},
agentVersion: {
display: "block",
},
accessLinks: {
display: "flex",
gap: theme.spacing(0.5),
@@ -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<React.PropsWithChildren<TooltipProps>> = ({ outdated }) => {
if (!outdated) {
return null
}
return (
<HelpTooltip size="small">
<HelpTooltipTitle>{Language.label}</HelpTooltipTitle>
<HelpTooltipText>{Language.text}</HelpTooltipText>
</HelpTooltip>
)
}
@@ -44,6 +44,7 @@ Started.args = {
builds: [Mocks.MockWorkspaceBuild],
canUpdateWorkspace: true,
workspaceErrors: {},
buildInfo: Mocks.MockBuildInfo,
}
export const WithoutUpdateAccess = Template.bind({})
@@ -44,6 +44,7 @@ export interface WorkspaceProps {
builds?: TypesGen.WorkspaceBuild[]
canUpdateWorkspace: boolean
workspaceErrors: Partial<Record<WorkspaceErrors, Error | unknown>>
buildInfo?: TypesGen.BuildInfoResponse
}
/**
@@ -62,6 +63,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
builds,
canUpdateWorkspace,
workspaceErrors,
buildInfo,
}) => {
const styles = useStyles()
const navigate = useNavigate()
@@ -128,6 +130,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
getResourcesError={workspaceErrors[WorkspaceErrors.GET_RESOURCES_ERROR]}
workspace={workspace}
canUpdateWorkspace={canUpdateWorkspace}
buildInfo={buildInfo}
/>
)}
@@ -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],
)
@@ -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}
/>
<DeleteWorkspaceDialog
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
+11 -1
View File
@@ -305,6 +305,7 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = {
wireguard_public_key: "",
disco_public_key: "",
ipv6: "",
version: MockBuildInfo.version,
}
export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = {
@@ -312,10 +313,19 @@ export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = {
id: "test-workspace-agent-2",
name: "another-workspace-agent",
status: "disconnected",
version: "",
}
export const MockWorkspaceAgentOutdated: TypesGen.WorkspaceAgent = {
...MockWorkspaceAgent,
id: "test-workspace-agent-3",
name: "an-outdated-workspace-agent",
version: "v99.999.9998+abcdef",
operating_system: "Windows",
}
export const MockWorkspaceResource: TypesGen.WorkspaceResource = {
agents: [MockWorkspaceAgent, MockWorkspaceAgentDisconnected],
agents: [MockWorkspaceAgent, MockWorkspaceAgentDisconnected, MockWorkspaceAgentOutdated],
created_at: "",
id: "test-workspace-resource",
job_id: "",
+20
View File
@@ -3,6 +3,7 @@ import * as TypesGen from "../api/typesGenerated"
import * as Mocks from "../testHelpers/entities"
import {
defaultWorkspaceExtension,
getDisplayVersionStatus,
getDisplayWorkspaceBuildInitiatedBy,
isWorkspaceDeleted,
isWorkspaceOn,
@@ -128,4 +129,23 @@ describe("util > 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)
},
)
})
})
+28
View File
@@ -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
+5
View File
@@ -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"