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" "cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/agent" "github.com/coder/coder/agent"
"github.com/coder/coder/agent/reaper" "github.com/coder/coder/agent/reaper"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk"
"github.com/coder/retry" "github.com/coder/retry"
@@ -73,7 +74,12 @@ func workspaceAgent() *cobra.Command {
return nil 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) client := codersdk.New(coderURL)
if pprofEnabled { if pprofEnabled {
@@ -172,6 +178,10 @@ func workspaceAgent() *cobra.Command {
return xerrors.Errorf("add executable to $PATH: %w", err) 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{ closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{
Logger: logger, Logger: logger,
EnvironmentVariables: map[string]string{ EnvironmentVariables: map[string]string{
+10
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest" "github.com/coder/coder/cli/clitest"
@@ -59,6 +60,9 @@ func TestWorkspaceAgent(t *testing.T) {
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err) 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) dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
require.NoError(t, err) require.NoError(t, err)
defer dialer.Close() defer dialer.Close()
@@ -114,6 +118,9 @@ func TestWorkspaceAgent(t *testing.T) {
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err) 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) dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
require.NoError(t, err) require.NoError(t, err)
defer dialer.Close() defer dialer.Close()
@@ -169,6 +176,9 @@ func TestWorkspaceAgent(t *testing.T) {
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err) 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) dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
require.NoError(t, err) require.NoError(t, err)
defer dialer.Close() defer dialer.Close()
+38 -13
View File
@@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/table"
"golang.org/x/mod/semver"
"github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database"
@@ -18,6 +19,7 @@ type WorkspaceResourcesOptions struct {
HideAgentState bool HideAgentState bool
HideAccess bool HideAccess bool
Title string Title string
ServerVersion string
} }
// WorkspaceResources displays the connection status and tree-view of provided resources. // 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"} row := table.Row{"Resource"}
if !options.HideAgentState { if !options.HideAgentState {
row = append(row, "Status") row = append(row, "Status")
row = append(row, "Version")
} }
if !options.HideAccess { if !options.HideAccess {
row = append(row, "Access") row = append(row, "Access")
@@ -91,21 +94,12 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
} }
if !options.HideAgentState { if !options.HideAgentState {
var agentStatus string var agentStatus string
var agentVersion string
if !options.HideAgentState { if !options.HideAgentState {
switch agent.Status { agentStatus = renderAgentStatus(agent)
case codersdk.WorkspaceAgentConnecting: agentVersion = renderAgentVersion(agent.Version, options.ServerVersion)
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")
}
} }
row = append(row, agentStatus) row = append(row, agentStatus, agentVersion)
} }
if !options.HideAccess { if !options.HideAccess {
sshCommand := "coder ssh " + options.WorkspaceName sshCommand := "coder ssh " + options.WorkspaceName
@@ -122,3 +116,34 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
_, err := fmt.Fprintln(writer, tableWriter.Render()) _, err := fmt.Fprintln(writer, tableWriter.Render())
return err 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 { if err != nil {
return err 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]) workspace, err := namedWorkspace(cmd, client, args[0])
if err != nil { if err != nil {
return xerrors.Errorf("get workspace: %w", err) return xerrors.Errorf("get workspace: %w", err)
@@ -28,6 +32,7 @@ func show() *cobra.Command {
} }
return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
WorkspaceName: workspace.Name, 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.Route("/me", func(r chi.Router) {
r.Use(httpmw.ExtractWorkspaceAgent(options.Database)) r.Use(httpmw.ExtractWorkspaceAgent(options.Database))
r.Get("/metadata", api.workspaceAgentMetadata) r.Get("/metadata", api.workspaceAgentMetadata)
r.Post("/version", api.postWorkspaceAgentVersion)
r.Get("/listen", api.workspaceAgentListen) r.Get("/listen", api.workspaceAgentListen)
r.Get("/gitsshkey", api.agentGitSSHKey) r.Get("/gitsshkey", api.agentGitSSHKey)
r.Get("/turn", api.workspaceAgentTurn) 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/metadata": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/derp": {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}, "GET:/api/v2/workspaceagents/me/wireguardlisten": {NoAuthorize: true},
"POST:/api/v2/workspaceagents/me/keys": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/me/keys": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {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 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 { func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.UpdateProvisionerJobByIDParams) error {
q.mutex.Lock() q.mutex.Lock()
defer q.mutex.Unlock() 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, directory character varying(4096) DEFAULT ''::character varying NOT NULL,
wireguard_node_ipv6 inet DEFAULT '::'::inet 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_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 ( CREATE TABLE workspace_apps (
id uuid NOT NULL, id uuid NOT NULL,
created_at timestamp with time zone 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"` WireguardNodeIPv6 pqtype.Inet `db:"wireguard_node_ipv6" json:"wireguard_node_ipv6"`
WireguardNodePublicKey dbtypes.NodePublic `db:"wireguard_node_public_key" json:"wireguard_node_public_key"` 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"` 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 { type WorkspaceApp struct {
+1
View File
@@ -144,6 +144,7 @@ type querier interface {
UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error)
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
UpdateWorkspaceAgentKeysByID(ctx context.Context, arg UpdateWorkspaceAgentKeysByIDParams) error UpdateWorkspaceAgentKeysByID(ctx context.Context, arg UpdateWorkspaceAgentKeysByIDParams) error
UpdateWorkspaceAgentVersionByID(ctx context.Context, arg UpdateWorkspaceAgentVersionByIDParams) error
UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error
UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) 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 const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one
SELECT 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 FROM
workspace_agents workspace_agents
WHERE WHERE
@@ -3191,13 +3191,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken
&i.WireguardNodeIPv6, &i.WireguardNodeIPv6,
&i.WireguardNodePublicKey, &i.WireguardNodePublicKey,
&i.WireguardDiscoPublicKey, &i.WireguardDiscoPublicKey,
&i.Version,
) )
return i, err return i, err
} }
const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one
SELECT 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 FROM
workspace_agents workspace_agents
WHERE WHERE
@@ -3228,13 +3229,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (W
&i.WireguardNodeIPv6, &i.WireguardNodeIPv6,
&i.WireguardNodePublicKey, &i.WireguardNodePublicKey,
&i.WireguardDiscoPublicKey, &i.WireguardDiscoPublicKey,
&i.Version,
) )
return i, err return i, err
} }
const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one
SELECT 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 FROM
workspace_agents workspace_agents
WHERE WHERE
@@ -3267,13 +3269,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst
&i.WireguardNodeIPv6, &i.WireguardNodeIPv6,
&i.WireguardNodePublicKey, &i.WireguardNodePublicKey,
&i.WireguardDiscoPublicKey, &i.WireguardDiscoPublicKey,
&i.Version,
) )
return i, err return i, err
} }
const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many
SELECT 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 FROM
workspace_agents workspace_agents
WHERE WHERE
@@ -3310,6 +3313,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []
&i.WireguardNodeIPv6, &i.WireguardNodeIPv6,
&i.WireguardNodePublicKey, &i.WireguardNodePublicKey,
&i.WireguardDiscoPublicKey, &i.WireguardDiscoPublicKey,
&i.Version,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -3325,7 +3329,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []
} }
const getWorkspaceAgentsCreatedAfter = `-- name: GetWorkspaceAgentsCreatedAfter :many 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) { 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.WireguardNodeIPv6,
&i.WireguardNodePublicKey, &i.WireguardNodePublicKey,
&i.WireguardDiscoPublicKey, &i.WireguardDiscoPublicKey,
&i.Version,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -3394,7 +3399,7 @@ INSERT INTO
wireguard_disco_public_key wireguard_disco_public_key
) )
VALUES 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 { type InsertWorkspaceAgentParams struct {
@@ -3459,6 +3464,7 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa
&i.WireguardNodeIPv6, &i.WireguardNodeIPv6,
&i.WireguardNodePublicKey, &i.WireguardNodePublicKey,
&i.WireguardDiscoPublicKey, &i.WireguardDiscoPublicKey,
&i.Version,
) )
return i, err return i, err
} }
@@ -3522,6 +3528,25 @@ func (q *sqlQuerier) UpdateWorkspaceAgentKeysByID(ctx context.Context, arg Updat
return err 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 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 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 updated_at = $4
WHERE WHERE
id = $1; 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/google/uuid"
"github.com/hashicorp/yamux" "github.com/hashicorp/yamux"
"github.com/tabbed/pqtype" "github.com/tabbed/pqtype"
"golang.org/x/mod/semver"
"golang.org/x/xerrors" "golang.org/x/xerrors"
"inet.af/netaddr" "inet.af/netaddr"
"nhooyr.io/websocket" "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) { func (api *API) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) {
api.websocketWaitMutex.Lock() api.websocketWaitMutex.Lock()
api.websocketWaitGroup.Add(1) api.websocketWaitGroup.Add(1)
@@ -707,6 +748,7 @@ func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, apps []codersdk.Work
IPv6: inetToNetaddr(dbAgent.WireguardNodeIPv6), IPv6: inetToNetaddr(dbAgent.WireguardNodeIPv6),
WireguardPublicKey: key.NodePublic(dbAgent.WireguardNodePublicKey), WireguardPublicKey: key.NodePublic(dbAgent.WireguardNodePublicKey),
DiscoPublicKey: key.DiscoPublic(dbAgent.WireguardDiscoPublicKey), DiscoPublicKey: key.DiscoPublic(dbAgent.WireguardDiscoPublicKey),
Version: dbAgent.Version,
} }
if dbAgent.FirstConnectedAt.Valid { if dbAgent.FirstConnectedAt.Valid {
+19
View File
@@ -48,6 +48,10 @@ type WorkspaceAgentAuthenticateResponse struct {
SessionToken string `json:"session_token"` SessionToken string `json:"session_token"`
} }
type PostWorkspaceAgentVersionRequest struct {
Version string `json:"version"`
}
// AuthWorkspaceGoogleInstanceIdentity uses the Google Compute Engine Metadata API to // AuthWorkspaceGoogleInstanceIdentity uses the Google Compute Engine Metadata API to
// fetch a signed JWT, and exchange it for a session token for a workspace agent. // 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 { if err != nil {
return agent.Metadata{}, nil, xerrors.Errorf("listen peerbroker: %w", err) 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) res, err = c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil)
if err != nil { if err != nil {
return agent.Metadata{}, nil, err 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) 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. // WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided.
// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON. // It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON.
// Responses are PTY output that can be rendered. // 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"` WireguardPublicKey key.NodePublic `json:"wireguard_public_key"`
DiscoPublicKey key.DiscoPublic `json:"disco_public_key"` DiscoPublicKey key.DiscoPublic `json:"disco_public_key"`
IPv6 netaddr.IPPrefix `json:"ipv6"` IPv6 netaddr.IPPrefix `json:"ipv6"`
Version string `json:"version"`
} }
type WorkspaceAgentResourceMetadata struct { type WorkspaceAgentResourceMetadata struct {
+2
View File
@@ -81,6 +81,7 @@
"@types/react": "18.0.15", "@types/react": "18.0.15",
"@types/react-dom": "18.0.6", "@types/react-dom": "18.0.6",
"@types/react-helmet": "6.1.5", "@types/react-helmet": "6.1.5",
"@types/semver": "^7.3.12",
"@types/superagent": "4.1.15", "@types/superagent": "4.1.15",
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",
"@typescript-eslint/eslint-plugin": "5.31.0", "@typescript-eslint/eslint-plugin": "5.31.0",
@@ -112,6 +113,7 @@
"prettier": "2.7.1", "prettier": "2.7.1",
"prettier-plugin-organize-imports": "3.0.0", "prettier-plugin-organize-imports": "3.0.0",
"react-hot-loader": "4.13.0", "react-hot-loader": "4.13.0",
"semver": "^7.3.7",
"sql-formatter": "8.2.0", "sql-formatter": "8.2.0",
"style-loader": "3.3.1", "style-loader": "3.3.1",
"ts-jest": "27.1.4", "ts-jest": "27.1.4",
+6
View File
@@ -249,6 +249,11 @@ export interface ParameterSchema {
readonly validation_contains?: string[] readonly validation_contains?: string[]
} }
// From codersdk/workspaceagents.go
export interface PostWorkspaceAgentVersionRequest {
readonly version: string
}
// From codersdk/provisionerdaemons.go // From codersdk/provisionerdaemons.go
export interface ProvisionerDaemon { export interface ProvisionerDaemon {
readonly id: string readonly id: string
@@ -479,6 +484,7 @@ export interface WorkspaceAgent {
// Named type "inet.af/netaddr.IPPrefix" unknown, using "any" // Named type "inet.af/netaddr.IPPrefix" unknown, using "any"
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly ipv6: any readonly ipv6: any
readonly version: string
} }
// From codersdk/workspaceagents.go // 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 { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { TableCellDataPrimary } from "components/TableCellData/TableCellData" import { TableCellDataPrimary } from "components/TableCellData/TableCellData"
import { FC } from "react" import { FC } from "react"
import { getDisplayAgentStatus } from "util/workspace" import { getDisplayAgentStatus, getDisplayVersionStatus } from "util/workspace"
import { Workspace, WorkspaceResource } from "../../api/typesGenerated" import { BuildInfoResponse, Workspace, WorkspaceResource } from "../../api/typesGenerated"
import { AppLink } from "../AppLink/AppLink" import { AppLink } from "../AppLink/AppLink"
import { SSHButton } from "../SSHButton/SSHButton" import { SSHButton } from "../SSHButton/SSHButton"
import { Stack } from "../Stack/Stack" import { Stack } from "../Stack/Stack"
import { TableHeaderRow } from "../TableHeaders/TableHeaders" import { TableHeaderRow } from "../TableHeaders/TableHeaders"
import { TerminalLink } from "../TerminalLink/TerminalLink" import { TerminalLink } from "../TerminalLink/TerminalLink"
import { AgentHelpTooltip } from "../Tooltips/AgentHelpTooltip" import { AgentHelpTooltip } from "../Tooltips/AgentHelpTooltip"
import { AgentOutdatedTooltip } from "../Tooltips/AgentOutdatedTooltip"
import { ResourcesHelpTooltip } from "../Tooltips/ResourcesHelpTooltip" import { ResourcesHelpTooltip } from "../Tooltips/ResourcesHelpTooltip"
import { ResourceAvatarData } from "./ResourceAvatarData" import { ResourceAvatarData } from "./ResourceAvatarData"
@@ -26,6 +27,7 @@ const Language = {
agentsLabel: "Agents", agentsLabel: "Agents",
agentLabel: "Agent", agentLabel: "Agent",
statusLabel: "status: ", statusLabel: "status: ",
versionLabel: "version: ",
osLabel: "os: ", osLabel: "os: ",
} }
@@ -34,6 +36,7 @@ interface ResourcesProps {
getResourcesError?: Error | unknown getResourcesError?: Error | unknown
workspace: Workspace workspace: Workspace
canUpdateWorkspace: boolean canUpdateWorkspace: boolean
buildInfo?: BuildInfoResponse | undefined
} }
export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
@@ -41,9 +44,11 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
getResourcesError, getResourcesError,
workspace, workspace,
canUpdateWorkspace, canUpdateWorkspace,
buildInfo,
}) => { }) => {
const styles = useStyles() const styles = useStyles()
const theme: Theme = useTheme() const theme: Theme = useTheme()
const serverVersion = buildInfo?.version || ""
return ( return (
<div aria-label={Language.resources} className={styles.wrapper}> <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) const agentStatus = getDisplayAgentStatus(theme, agent)
return ( return (
<TableRow key={`${resource.id}-${agent.id}`}> <TableRow key={`${resource.id}-${agent.id}`}>
@@ -114,6 +123,11 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
<strong>{Language.osLabel}</strong> <strong>{Language.osLabel}</strong>
<span className={styles.operatingSystem}>{agent.operating_system}</span> <span className={styles.operatingSystem}>{agent.operating_system}</span>
</div> </div>
<div className={styles.dataRow}>
<strong>{Language.versionLabel}</strong>
<span className={styles.agentVersion}>{displayVersion}</span>
<AgentOutdatedTooltip outdated={outdated} />
</div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -188,6 +202,10 @@ const useStyles = makeStyles((theme) => ({
textTransform: "capitalize", textTransform: "capitalize",
}, },
agentVersion: {
display: "block",
},
accessLinks: { accessLinks: {
display: "flex", display: "flex",
gap: theme.spacing(0.5), 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], builds: [Mocks.MockWorkspaceBuild],
canUpdateWorkspace: true, canUpdateWorkspace: true,
workspaceErrors: {}, workspaceErrors: {},
buildInfo: Mocks.MockBuildInfo,
} }
export const WithoutUpdateAccess = Template.bind({}) export const WithoutUpdateAccess = Template.bind({})
@@ -44,6 +44,7 @@ export interface WorkspaceProps {
builds?: TypesGen.WorkspaceBuild[] builds?: TypesGen.WorkspaceBuild[]
canUpdateWorkspace: boolean canUpdateWorkspace: boolean
workspaceErrors: Partial<Record<WorkspaceErrors, Error | unknown>> workspaceErrors: Partial<Record<WorkspaceErrors, Error | unknown>>
buildInfo?: TypesGen.BuildInfoResponse
} }
/** /**
@@ -62,6 +63,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
builds, builds,
canUpdateWorkspace, canUpdateWorkspace,
workspaceErrors, workspaceErrors,
buildInfo,
}) => { }) => {
const styles = useStyles() const styles = useStyles()
const navigate = useNavigate() const navigate = useNavigate()
@@ -128,6 +130,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
getResourcesError={workspaceErrors[WorkspaceErrors.GET_RESOURCES_ERROR]} getResourcesError={workspaceErrors[WorkspaceErrors.GET_RESOURCES_ERROR]}
workspace={workspace} workspace={workspace}
canUpdateWorkspace={canUpdateWorkspace} canUpdateWorkspace={canUpdateWorkspace}
buildInfo={buildInfo}
/> />
)} )}
@@ -204,7 +204,7 @@ describe("WorkspacePage", () => {
const agent1Status = await screen.findAllByText( const agent1Status = await screen.findAllByText(
DisplayAgentStatusLanguage[MockWorkspaceAgent.status], DisplayAgentStatusLanguage[MockWorkspaceAgent.status],
) )
expect(agent1Status.length).toEqual(2) expect(agent1Status.length).toEqual(4)
const agent2Status = await screen.findAllByText( const agent2Status = await screen.findAllByText(
DisplayAgentStatusLanguage[MockWorkspaceAgentDisconnected.status], DisplayAgentStatusLanguage[MockWorkspaceAgentDisconnected.status],
) )
@@ -1,5 +1,5 @@
import { makeStyles } from "@material-ui/core/styles" 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 dayjs from "dayjs"
import minMax from "dayjs/plugin/minMax" import minMax from "dayjs/plugin/minMax"
import { FC, useContext, useEffect } from "react" import { FC, useContext, useEffect } from "react"
@@ -51,6 +51,7 @@ export const WorkspacePage: FC = () => {
const canUpdateWorkspace = !!permissions?.updateWorkspace const canUpdateWorkspace = !!permissions?.updateWorkspace
const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine) const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine)
const [buildInfoState] = useActor(xServices.buildInfoXService)
const styles = useStyles() const styles = useStyles()
@@ -133,6 +134,7 @@ export const WorkspacePage: FC = () => {
[WorkspaceErrors.BUILD_ERROR]: buildError, [WorkspaceErrors.BUILD_ERROR]: buildError,
[WorkspaceErrors.CANCELLATION_ERROR]: cancellationError, [WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
}} }}
buildInfo={buildInfoState.context.buildInfo}
/> />
<DeleteWorkspaceDialog <DeleteWorkspaceDialog
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })} isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
+11 -1
View File
@@ -305,6 +305,7 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = {
wireguard_public_key: "", wireguard_public_key: "",
disco_public_key: "", disco_public_key: "",
ipv6: "", ipv6: "",
version: MockBuildInfo.version,
} }
export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = { export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = {
@@ -312,10 +313,19 @@ export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = {
id: "test-workspace-agent-2", id: "test-workspace-agent-2",
name: "another-workspace-agent", name: "another-workspace-agent",
status: "disconnected", 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 = { export const MockWorkspaceResource: TypesGen.WorkspaceResource = {
agents: [MockWorkspaceAgent, MockWorkspaceAgentDisconnected], agents: [MockWorkspaceAgent, MockWorkspaceAgentDisconnected, MockWorkspaceAgentOutdated],
created_at: "", created_at: "",
id: "test-workspace-resource", id: "test-workspace-resource",
job_id: "", job_id: "",
+20
View File
@@ -3,6 +3,7 @@ import * as TypesGen from "../api/typesGenerated"
import * as Mocks from "../testHelpers/entities" import * as Mocks from "../testHelpers/entities"
import { import {
defaultWorkspaceExtension, defaultWorkspaceExtension,
getDisplayVersionStatus,
getDisplayWorkspaceBuildInitiatedBy, getDisplayWorkspaceBuildInitiatedBy,
isWorkspaceDeleted, isWorkspaceDeleted,
isWorkspaceOn, isWorkspaceOn,
@@ -128,4 +129,23 @@ describe("util > workspace", () => {
expect(getDisplayWorkspaceBuildInitiatedBy(build)).toEqual(initiatedBy) 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 duration from "dayjs/plugin/duration"
import minMax from "dayjs/plugin/minMax" import minMax from "dayjs/plugin/minMax"
import utc from "dayjs/plugin/utc" import utc from "dayjs/plugin/utc"
import semver from "semver"
import { WorkspaceBuildTransition } from "../api/types" import { WorkspaceBuildTransition } from "../api/types"
import * as TypesGen from "../api/typesGenerated" import * as TypesGen from "../api/typesGenerated"
@@ -95,6 +96,11 @@ export const DisplayWorkspaceBuildStatusLanguage = {
failed: "Failed", failed: "Failed",
} }
export const DisplayAgentVersionLanguage = {
unknown: "unknown",
outdated: "outdated",
}
export const getDisplayWorkspaceBuildStatus = ( export const getDisplayWorkspaceBuildStatus = (
theme: Theme, theme: Theme,
build: TypesGen.WorkspaceBuild, 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 => { export const isWorkspaceOn = (workspace: TypesGen.Workspace): boolean => {
const transition = workspace.latest_build.transition const transition = workspace.latest_build.transition
const status = workspace.latest_build.job.status 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" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== 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": "@types/serve-index@^1.9.1":
version "1.9.1" version "1.9.1"
resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278" resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278"