mirror of
https://github.com/coder/coder.git
synced 2026-10-16 06:37:58 +08:00
feat: send script resource addresses and dependencies in the agent manifest (#30627)
feat: include script dependencies in agent manifests Send persisted script Terraform addresses and dependency lists on every manifest request, preserving them across agent restarts, reconnects, and shutdown without rebuilding the workspace. - Add `resource_address` and `dependencies` to the agent proto, with `SUCCESS` and `COMPLETION` requirements. - Reuse shared database-to-SDK validation and add bidirectional SDK/proto conversion. Reject empty prerequisite addresses and invalid requirements rather than accepting partial lists. - Bump the agent API to v2.13 and update the agent's RPC connection. Older agents ignore the new fields; older servers return empty fields. - Test manifest delivery, subagent isolation, round trips, invalid dependencies, and unchanged encoding when the new fields are unset. Script execution is unchanged; ordering enforcement follows in PLAT-549. Production dependency lists remain empty until #30556 merges. Stacked on #30625. Refs PLAT-548.
This commit is contained in:
+4
-4
@@ -128,10 +128,10 @@ type Options struct {
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
// ConnectRPC213WithRole connects to the Agent API v2.13. The workspace
|
||||
// ConnectRPC214WithRole connects to the Agent API v2.14. The workspace
|
||||
// agent should use role "agent" to enable connection monitoring.
|
||||
ConnectRPC213WithRole(ctx context.Context, role string) (
|
||||
proto.DRPCAgentClient213, tailnetproto.DRPCTailnetClient28, error,
|
||||
ConnectRPC214WithRole(ctx context.Context, role string) (
|
||||
proto.DRPCAgentClient214, tailnetproto.DRPCTailnetClient28, error,
|
||||
)
|
||||
tailnet.DERPMapRewriter
|
||||
agentsdk.RefreshableSessionTokenProvider
|
||||
@@ -1008,7 +1008,7 @@ func (a *agent) run() (retErr error) {
|
||||
// ConnectRPC returns the dRPC connection we use for the Agent and Tailnet v2+ APIs.
|
||||
// We pass role "agent" to enable connection monitoring on the server, which tracks
|
||||
// the agent's connectivity state (first_connected_at, last_connected_at, disconnected_at).
|
||||
aAPI, tAPI, err := a.client.ConnectRPC213WithRole(a.hardCtx, "agent")
|
||||
aAPI, tAPI, err := a.client.ConnectRPC214WithRole(a.hardCtx, "agent")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+2
-2
@@ -131,8 +131,8 @@ type eofClient struct {
|
||||
*agenttest.Client
|
||||
}
|
||||
|
||||
func (*eofClient) ConnectRPC213WithRole(context.Context, string) (
|
||||
proto.DRPCAgentClient213, tailnetproto.DRPCTailnetClient28, error,
|
||||
func (*eofClient) ConnectRPC214WithRole(context.Context, string) (
|
||||
proto.DRPCAgentClient214, tailnetproto.DRPCTailnetClient28, error,
|
||||
) {
|
||||
return nil, nil, io.EOF
|
||||
}
|
||||
|
||||
@@ -199,6 +199,13 @@ func (c *Client) ConnectRPC213WithRole(ctx context.Context, role string) (
|
||||
return c.ConnectRPC212WithRole(ctx, role)
|
||||
}
|
||||
|
||||
// v2.14 adds no RPCs, so the v2.13 client satisfies it as-is.
|
||||
func (c *Client) ConnectRPC214WithRole(ctx context.Context, role string) (
|
||||
agentproto.DRPCAgentClient214, proto.DRPCTailnetClient28, error,
|
||||
) {
|
||||
return c.ConnectRPC213WithRole(ctx, role)
|
||||
}
|
||||
|
||||
func (c *Client) ConnectRPC29(ctx context.Context) (
|
||||
agentproto.DRPCAgentClient29, proto.DRPCTailnetClient28, error,
|
||||
) {
|
||||
|
||||
Generated
+1567
-1394
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,30 @@ message WorkspaceAgentScript {
|
||||
google.protobuf.Duration timeout = 8;
|
||||
string display_name = 9;
|
||||
bytes id = 10;
|
||||
// resource_address is the full Terraform address of the coder_script
|
||||
// resource, for example "coder_script.install_tools". Empty for scripts
|
||||
// that are not coder_script resources.
|
||||
string resource_address = 11;
|
||||
// dependencies lists the scripts this one waits for, in the order the
|
||||
// provisioner resolved them. Empty when no rule orders this script.
|
||||
repeated WorkspaceAgentScriptDependency dependencies = 12;
|
||||
}
|
||||
|
||||
// WorkspaceAgentScriptDependency names a prerequisite script and the
|
||||
// outcome it must reach before the dependent script may run.
|
||||
message WorkspaceAgentScriptDependency {
|
||||
// The agent rejects a Requirement value it does not know and fails its
|
||||
// whole manifest, which keeps networking, SSH and apps down until the
|
||||
// agent binary is replaced. So coderd must send a value only to agents
|
||||
// whose reported API version is at or above the version that added it.
|
||||
enum Requirement {
|
||||
REQUIREMENT_UNSPECIFIED = 0;
|
||||
REQUIREMENT_SUCCESS = 1;
|
||||
REQUIREMENT_COMPLETION = 2;
|
||||
}
|
||||
|
||||
string prerequisite_resource_address = 1;
|
||||
Requirement requirement = 2;
|
||||
}
|
||||
|
||||
message WorkspaceAgentMetadata {
|
||||
|
||||
@@ -117,3 +117,9 @@ type DRPCAgentClient212 interface {
|
||||
type DRPCAgentClient213 interface {
|
||||
DRPCAgentClient212
|
||||
}
|
||||
|
||||
// DRPCAgentClient214 is the Agent API at v2.14. It adds resource_address and
|
||||
// dependencies to WorkspaceAgentScript in the manifest. No new RPCs.
|
||||
type DRPCAgentClient214 interface {
|
||||
DRPCAgentClient213
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package proto_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
)
|
||||
|
||||
func TestScriptDependenciesRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, logSourceID := uuid.New(), uuid.New()
|
||||
want := &agentproto.Manifest{
|
||||
Scripts: []*agentproto.WorkspaceAgentScript{{
|
||||
Id: id[:],
|
||||
LogSourceId: logSourceID[:],
|
||||
DisplayName: "Install tools",
|
||||
RunOnStart: true,
|
||||
ResourceAddress: "coder_script.install_tools",
|
||||
Dependencies: []*agentproto.WorkspaceAgentScriptDependency{
|
||||
{
|
||||
PrerequisiteResourceAddress: "coder_script.clone_repo",
|
||||
Requirement: agentproto.WorkspaceAgentScriptDependency_REQUIREMENT_SUCCESS,
|
||||
},
|
||||
{
|
||||
PrerequisiteResourceAddress: "coder_script.warm_cache",
|
||||
Requirement: agentproto.WorkspaceAgentScriptDependency_REQUIREMENT_COMPLETION,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
data, err := proto.Marshal(want)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := &agentproto.Manifest{}
|
||||
require.NoError(t, proto.Unmarshal(data, got))
|
||||
require.True(t, proto.Equal(want, got))
|
||||
}
|
||||
|
||||
// A script with neither new field set must encode to the same bytes it did
|
||||
// before fields 11 and 12 existed, so an older agent sees nothing new.
|
||||
// Walking the tags instead of comparing against a recorded byte string
|
||||
// keeps the test stable when unrelated fields are added later.
|
||||
func TestUnorderedScriptEncodesWithoutNewFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, logSourceID := uuid.New(), uuid.New()
|
||||
script := &agentproto.WorkspaceAgentScript{
|
||||
Id: id[:],
|
||||
LogSourceId: logSourceID[:],
|
||||
Script: "git clone ...",
|
||||
RunOnStart: true,
|
||||
Timeout: durationpb.New(2 * time.Minute),
|
||||
DisplayName: "Clone repo",
|
||||
}
|
||||
data, err := proto.Marshal(script)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, data)
|
||||
|
||||
for len(data) > 0 {
|
||||
num, typ, n := protowire.ConsumeTag(data)
|
||||
require.Positive(t, n)
|
||||
require.LessOrEqual(t, int(num), 10, "field %d must not be encoded when it is not set", num)
|
||||
data = data[n:]
|
||||
|
||||
n = protowire.ConsumeFieldValue(num, typ, data)
|
||||
require.Positive(t, n)
|
||||
data = data[n:]
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,11 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
|
||||
return nil, xerrors.Errorf("converting workspace apps: %w", err)
|
||||
}
|
||||
|
||||
protoScripts, err := dbAgentScriptsToProto(scripts)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("converting workspace agent scripts: %w", err)
|
||||
}
|
||||
|
||||
var parentID []byte
|
||||
if workspaceAgent.ParentID.Valid {
|
||||
parentID = workspaceAgent.ParentID.UUID[:]
|
||||
@@ -151,7 +156,7 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
|
||||
ParentId: parentID,
|
||||
|
||||
DerpMap: tailnet.DERPMapToProto(a.DerpMapFn()),
|
||||
Scripts: dbAgentScriptsToProto(scripts),
|
||||
Scripts: protoScripts,
|
||||
Apps: apps,
|
||||
Metadata: dbAgentMetadataToProtoDescription(metadata),
|
||||
Devcontainers: dbAgentDevcontainersToProto(devcontainers),
|
||||
@@ -190,15 +195,40 @@ func dbAgentMetadatumToProtoDescription(metadatum database.WorkspaceAgentMetadat
|
||||
}
|
||||
}
|
||||
|
||||
func dbAgentScriptsToProto(scripts []database.GetWorkspaceAgentScriptsByAgentIDsRow) []*agentproto.WorkspaceAgentScript {
|
||||
func dbAgentScriptsToProto(scripts []database.GetWorkspaceAgentScriptsByAgentIDsRow) ([]*agentproto.WorkspaceAgentScript, error) {
|
||||
ret := make([]*agentproto.WorkspaceAgentScript, len(scripts))
|
||||
for i, script := range scripts {
|
||||
ret[i] = dbAgentScriptToProto(script)
|
||||
converted, err := dbAgentScriptToProto(script)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("script %s: %w", script.ID, err)
|
||||
}
|
||||
ret[i] = converted
|
||||
}
|
||||
return ret
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func dbAgentScriptToProto(script database.GetWorkspaceAgentScriptsByAgentIDsRow) *agentproto.WorkspaceAgentScript {
|
||||
// dbAgentScriptToProto goes through db2sdk.WorkspaceAgentScript so the
|
||||
// manifest and the REST API agree on what a valid dependency list is.
|
||||
func dbAgentScriptToProto(script database.GetWorkspaceAgentScriptsByAgentIDsRow) (*agentproto.WorkspaceAgentScript, error) {
|
||||
sdkScript, err := db2sdk.WorkspaceAgentScript(script)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dependencies []*agentproto.WorkspaceAgentScriptDependency
|
||||
if len(sdkScript.Dependencies) > 0 {
|
||||
dependencies = make([]*agentproto.WorkspaceAgentScriptDependency, len(sdkScript.Dependencies))
|
||||
}
|
||||
for i, dependency := range sdkScript.Dependencies {
|
||||
requirement, err := workspaceAgentScriptDependencyRequirementToProto(dependency.Requirement)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dependency %d: %w", i, err)
|
||||
}
|
||||
dependencies[i] = &agentproto.WorkspaceAgentScriptDependency{
|
||||
PrerequisiteResourceAddress: dependency.PrerequisiteResourceAddress,
|
||||
Requirement: requirement,
|
||||
}
|
||||
}
|
||||
|
||||
return &agentproto.WorkspaceAgentScript{
|
||||
Id: script.ID[:],
|
||||
LogSourceId: script.LogSourceID[:],
|
||||
@@ -209,6 +239,21 @@ func dbAgentScriptToProto(script database.GetWorkspaceAgentScriptsByAgentIDsRow)
|
||||
RunOnStop: script.RunOnStop,
|
||||
StartBlocksLogin: script.StartBlocksLogin,
|
||||
Timeout: durationpb.New(time.Duration(script.TimeoutSeconds) * time.Second),
|
||||
ResourceAddress: sdkScript.ResourceAddress,
|
||||
Dependencies: dependencies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func workspaceAgentScriptDependencyRequirementToProto(requirement codersdk.WorkspaceAgentScriptDependencyRequirement) (agentproto.WorkspaceAgentScriptDependency_Requirement, error) {
|
||||
switch requirement {
|
||||
case codersdk.WorkspaceAgentScriptDependencyRequirementSuccess:
|
||||
return agentproto.WorkspaceAgentScriptDependency_REQUIREMENT_SUCCESS, nil
|
||||
case codersdk.WorkspaceAgentScriptDependencyRequirementCompletion:
|
||||
return agentproto.WorkspaceAgentScriptDependency_REQUIREMENT_COMPLETION, nil
|
||||
default:
|
||||
// db2sdk already rejected anything else. The error stays so a new
|
||||
// requirement can never fall through to UNSPECIFIED unnoticed.
|
||||
return agentproto.WorkspaceAgentScriptDependency_REQUIREMENT_UNSPECIFIED, xerrors.Errorf("unsupported requirement %q", requirement)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ func TestGetManifest(t *testing.T) {
|
||||
RunOnStart: true,
|
||||
RunOnStop: false,
|
||||
TimeoutSeconds: 60,
|
||||
ResourceAddress: "coder_script.install_tools",
|
||||
Dependencies: json.RawMessage(`[{"prerequisite_resource_address":"coder_script.clone_repo","requirement":"success"}]`),
|
||||
},
|
||||
{
|
||||
ID: uuid.New(),
|
||||
@@ -138,6 +140,8 @@ func TestGetManifest(t *testing.T) {
|
||||
RunOnStart: false,
|
||||
RunOnStop: true,
|
||||
TimeoutSeconds: 30,
|
||||
ResourceAddress: "coder_script.clone_repo",
|
||||
Dependencies: json.RawMessage(`[]`),
|
||||
},
|
||||
}
|
||||
metadata = []database.WorkspaceAgentMetadatum{
|
||||
@@ -202,6 +206,11 @@ func TestGetManifest(t *testing.T) {
|
||||
RunOnStop: scripts[0].RunOnStop,
|
||||
StartBlocksLogin: scripts[0].StartBlocksLogin,
|
||||
Timeout: durationpb.New(time.Duration(scripts[0].TimeoutSeconds) * time.Second),
|
||||
ResourceAddress: "coder_script.install_tools",
|
||||
Dependencies: []*agentproto.WorkspaceAgentScriptDependency{{
|
||||
PrerequisiteResourceAddress: "coder_script.clone_repo",
|
||||
Requirement: agentproto.WorkspaceAgentScriptDependency_REQUIREMENT_SUCCESS,
|
||||
}},
|
||||
},
|
||||
{
|
||||
Id: scripts[1].ID[:],
|
||||
@@ -213,6 +222,9 @@ func TestGetManifest(t *testing.T) {
|
||||
RunOnStop: scripts[1].RunOnStop,
|
||||
StartBlocksLogin: scripts[1].StartBlocksLogin,
|
||||
Timeout: durationpb.New(time.Duration(scripts[1].TimeoutSeconds) * time.Second),
|
||||
ResourceAddress: "coder_script.clone_repo",
|
||||
// An unordered script sends no entries, not placeholders.
|
||||
Dependencies: nil,
|
||||
},
|
||||
}
|
||||
protoMetadata = []*agentproto.WorkspaceAgentMetadata_Description{
|
||||
@@ -436,6 +448,117 @@ func TestGetManifest(t *testing.T) {
|
||||
require.Equal(t, expected, got)
|
||||
})
|
||||
|
||||
t.Run("SubagentScriptDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mDB := dbmock.NewMockStore(gomock.NewController(t))
|
||||
|
||||
api := &agentapi.ManifestAPI{
|
||||
AccessURL: &url.URL{Scheme: "https", Host: "example.com"},
|
||||
AppHostname: "*--apps.example.com",
|
||||
|
||||
AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return childAgent, nil },
|
||||
WorkspaceID: workspace.ID,
|
||||
Database: mDB,
|
||||
DerpMapFn: derpMapFn,
|
||||
}
|
||||
|
||||
subagentScripts := []database.GetWorkspaceAgentScriptsByAgentIDsRow{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
WorkspaceAgentID: childAgent.ID,
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo a",
|
||||
RunOnStart: true,
|
||||
ResourceAddress: "coder_script.a",
|
||||
Dependencies: json.RawMessage(`[]`),
|
||||
},
|
||||
{
|
||||
ID: uuid.New(),
|
||||
WorkspaceAgentID: childAgent.ID,
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo b",
|
||||
RunOnStart: true,
|
||||
ResourceAddress: "coder_script.b",
|
||||
Dependencies: json.RawMessage(`[{"prerequisite_resource_address":"coder_script.a","requirement":"completion"}]`),
|
||||
},
|
||||
}
|
||||
|
||||
mDB.EXPECT().GetWorkspaceAppsByAgentID(gomock.Any(), childAgent.ID).Return([]database.WorkspaceApp{}, nil)
|
||||
// Only the subagent's own rows are loaded, never the parent's.
|
||||
mDB.EXPECT().GetWorkspaceAgentScriptsByAgentIDs(gomock.Any(), []uuid.UUID{childAgent.ID}).Return(subagentScripts, nil)
|
||||
mDB.EXPECT().GetWorkspaceAgentMetadata(gomock.Any(), database.GetWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: childAgent.ID,
|
||||
Keys: nil, // all
|
||||
}).Return([]database.WorkspaceAgentMetadatum{}, nil)
|
||||
mDB.EXPECT().GetWorkspaceAgentDevcontainersByAgentID(gomock.Any(), childAgent.ID).Return([]database.WorkspaceAgentDevcontainer{}, nil)
|
||||
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
|
||||
mDB.EXPECT().ListUserSecretsWithValues(gomock.Any(), workspace.OwnerID).Return(nil, nil)
|
||||
|
||||
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, []*agentproto.WorkspaceAgentScript{
|
||||
{
|
||||
Id: subagentScripts[0].ID[:],
|
||||
LogSourceId: subagentScripts[0].LogSourceID[:],
|
||||
Script: "echo a",
|
||||
RunOnStart: true,
|
||||
Timeout: durationpb.New(0),
|
||||
ResourceAddress: "coder_script.a",
|
||||
},
|
||||
{
|
||||
Id: subagentScripts[1].ID[:],
|
||||
LogSourceId: subagentScripts[1].LogSourceID[:],
|
||||
Script: "echo b",
|
||||
RunOnStart: true,
|
||||
Timeout: durationpb.New(0),
|
||||
ResourceAddress: "coder_script.b",
|
||||
Dependencies: []*agentproto.WorkspaceAgentScriptDependency{{
|
||||
PrerequisiteResourceAddress: "coder_script.a",
|
||||
Requirement: agentproto.WorkspaceAgentScriptDependency_REQUIREMENT_COMPLETION,
|
||||
}},
|
||||
},
|
||||
}, got.Scripts)
|
||||
})
|
||||
|
||||
t.Run("CorruptScriptDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mDB := dbmock.NewMockStore(gomock.NewController(t))
|
||||
|
||||
api := &agentapi.ManifestAPI{
|
||||
AccessURL: &url.URL{Scheme: "https", Host: "example.com"},
|
||||
AppHostname: "*--apps.example.com",
|
||||
|
||||
AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return agent, nil },
|
||||
WorkspaceID: workspace.ID,
|
||||
Database: mDB,
|
||||
DerpMapFn: derpMapFn,
|
||||
}
|
||||
|
||||
corruptScripts := []database.GetWorkspaceAgentScriptsByAgentIDsRow{{
|
||||
ID: uuid.New(),
|
||||
WorkspaceAgentID: agent.ID,
|
||||
LogSourceID: uuid.New(),
|
||||
ResourceAddress: "coder_script.install_tools",
|
||||
Dependencies: json.RawMessage(`[{"requirement":"success"}]`),
|
||||
}}
|
||||
|
||||
mDB.EXPECT().GetWorkspaceAppsByAgentID(gomock.Any(), agent.ID).Return([]database.WorkspaceApp{}, nil)
|
||||
mDB.EXPECT().GetWorkspaceAgentScriptsByAgentIDs(gomock.Any(), []uuid.UUID{agent.ID}).Return(corruptScripts, nil)
|
||||
mDB.EXPECT().GetWorkspaceAgentMetadata(gomock.Any(), database.GetWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agent.ID,
|
||||
Keys: nil, // all
|
||||
}).Return([]database.WorkspaceAgentMetadatum{}, nil)
|
||||
mDB.EXPECT().GetWorkspaceAgentDevcontainersByAgentID(gomock.Any(), agent.ID).Return([]database.WorkspaceAgentDevcontainer{}, nil)
|
||||
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
|
||||
mDB.EXPECT().ListUserSecretsWithValues(gomock.Any(), workspace.OwnerID).Return(nil, nil)
|
||||
|
||||
_, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
|
||||
require.ErrorContains(t, err, "empty prerequisite resource address")
|
||||
})
|
||||
|
||||
t.Run("SecretsFiltering", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -388,6 +388,19 @@ func (c *Client) ConnectRPC213WithRole(ctx context.Context, role string) (
|
||||
return proto.NewDRPCAgentClient(conn), tailnetproto.NewDRPCTailnetClient(conn), nil
|
||||
}
|
||||
|
||||
// ConnectRPC214WithRole returns a dRPC client to the Agent API v2.14, which
|
||||
// adds resource_address and dependencies to each script in the manifest. Pass
|
||||
// role "agent" for workspace agents to enable connection monitoring.
|
||||
func (c *Client) ConnectRPC214WithRole(ctx context.Context, role string) (
|
||||
proto.DRPCAgentClient214, tailnetproto.DRPCTailnetClient28, error,
|
||||
) {
|
||||
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 14), role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return proto.NewDRPCAgentClient(conn), tailnetproto.NewDRPCTailnetClient(conn), nil
|
||||
}
|
||||
|
||||
// ConnectRPC connects to the workspace agent API and tailnet API.
|
||||
// It does not send a role query parameter, so the server will apply
|
||||
// its default behavior (currently: enable connection monitoring for
|
||||
|
||||
@@ -78,6 +78,10 @@ func ProtoFromManifest(manifest Manifest) (*proto.Manifest, error) {
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("convert workspace apps: %w", err)
|
||||
}
|
||||
scripts, err := ProtoFromScripts(manifest.Scripts)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("convert workspace agent scripts: %w", err)
|
||||
}
|
||||
return &proto.Manifest{
|
||||
ParentId: manifest.ParentID[:],
|
||||
AgentId: manifest.AgentID[:],
|
||||
@@ -94,7 +98,7 @@ func ProtoFromManifest(manifest Manifest) (*proto.Manifest, error) {
|
||||
DisableDirectConnections: manifest.DisableDirectConnections,
|
||||
DerpForceWebsockets: manifest.DERPForceWebSockets,
|
||||
DerpMap: tailnet.DERPMapToProto(manifest.DERPMap),
|
||||
Scripts: ProtoFromScripts(manifest.Scripts),
|
||||
Scripts: scripts,
|
||||
Apps: apps,
|
||||
Metadata: ProtoFromMetadataDescriptions(manifest.Metadata),
|
||||
Devcontainers: ProtoFromDevcontainers(manifest.Devcontainers),
|
||||
@@ -174,12 +178,16 @@ func AgentScriptsFromProto(protoScripts []*proto.WorkspaceAgentScript) ([]coders
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func ProtoFromScripts(scripts []codersdk.WorkspaceAgentScript) []*proto.WorkspaceAgentScript {
|
||||
func ProtoFromScripts(scripts []codersdk.WorkspaceAgentScript) ([]*proto.WorkspaceAgentScript, error) {
|
||||
ret := make([]*proto.WorkspaceAgentScript, len(scripts))
|
||||
for i, script := range scripts {
|
||||
ret[i] = ProtoFromScript(script)
|
||||
converted, err := ProtoFromScript(script)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("script %d: %w", i, err)
|
||||
}
|
||||
ret[i] = converted
|
||||
}
|
||||
return ret
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func AgentScriptFromProto(protoScript *proto.WorkspaceAgentScript) (codersdk.WorkspaceAgentScript, error) {
|
||||
@@ -193,6 +201,11 @@ func AgentScriptFromProto(protoScript *proto.WorkspaceAgentScript) (codersdk.Wor
|
||||
return codersdk.WorkspaceAgentScript{}, xerrors.Errorf("parse log source id: %w", err)
|
||||
}
|
||||
|
||||
dependencies, err := agentScriptDependenciesFromProto(protoScript.Dependencies)
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceAgentScript{}, xerrors.Errorf("parse dependencies: %w", err)
|
||||
}
|
||||
|
||||
return codersdk.WorkspaceAgentScript{
|
||||
ID: id,
|
||||
LogSourceID: logSourceID,
|
||||
@@ -204,10 +217,16 @@ func AgentScriptFromProto(protoScript *proto.WorkspaceAgentScript) (codersdk.Wor
|
||||
StartBlocksLogin: protoScript.StartBlocksLogin,
|
||||
Timeout: protoScript.Timeout.AsDuration(),
|
||||
DisplayName: protoScript.DisplayName,
|
||||
ResourceAddress: protoScript.ResourceAddress,
|
||||
Dependencies: dependencies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ProtoFromScript(s codersdk.WorkspaceAgentScript) *proto.WorkspaceAgentScript {
|
||||
func ProtoFromScript(s codersdk.WorkspaceAgentScript) (*proto.WorkspaceAgentScript, error) {
|
||||
dependencies, err := protoFromAgentScriptDependencies(s.Dependencies)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("convert dependencies: %w", err)
|
||||
}
|
||||
return &proto.WorkspaceAgentScript{
|
||||
Id: s.ID[:],
|
||||
LogSourceId: s.LogSourceID[:],
|
||||
@@ -219,6 +238,78 @@ func ProtoFromScript(s codersdk.WorkspaceAgentScript) *proto.WorkspaceAgentScrip
|
||||
StartBlocksLogin: s.StartBlocksLogin,
|
||||
Timeout: durationpb.New(s.Timeout),
|
||||
DisplayName: s.DisplayName,
|
||||
ResourceAddress: s.ResourceAddress,
|
||||
Dependencies: dependencies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// agentScriptDependenciesFromProto rejects an empty address or an unknown
|
||||
// enum value, including UNSPECIFIED. The error fails the whole manifest,
|
||||
// so the agent stays offline rather than start scripts from a list it only
|
||||
// half understands. Coderd avoids this by sending a new Requirement value
|
||||
// only to agents on the API version that added it; see the enum in
|
||||
// agent.proto.
|
||||
func agentScriptDependenciesFromProto(dependencies []*proto.WorkspaceAgentScriptDependency) ([]codersdk.WorkspaceAgentScriptDependency, error) {
|
||||
if len(dependencies) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
converted := make([]codersdk.WorkspaceAgentScriptDependency, len(dependencies))
|
||||
for i, dependency := range dependencies {
|
||||
if dependency.GetPrerequisiteResourceAddress() == "" {
|
||||
return nil, xerrors.Errorf("dependency %d has an empty prerequisite resource address", i)
|
||||
}
|
||||
requirement, err := agentScriptDependencyRequirementFromProto(dependency.GetRequirement())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dependency %d: %w", i, err)
|
||||
}
|
||||
converted[i] = codersdk.WorkspaceAgentScriptDependency{
|
||||
PrerequisiteResourceAddress: dependency.GetPrerequisiteResourceAddress(),
|
||||
Requirement: requirement,
|
||||
}
|
||||
}
|
||||
return converted, nil
|
||||
}
|
||||
|
||||
func protoFromAgentScriptDependencies(dependencies []codersdk.WorkspaceAgentScriptDependency) ([]*proto.WorkspaceAgentScriptDependency, error) {
|
||||
if len(dependencies) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
converted := make([]*proto.WorkspaceAgentScriptDependency, len(dependencies))
|
||||
for i, dependency := range dependencies {
|
||||
if dependency.PrerequisiteResourceAddress == "" {
|
||||
return nil, xerrors.Errorf("dependency %d has an empty prerequisite resource address", i)
|
||||
}
|
||||
requirement, err := agentScriptDependencyRequirementToProto(dependency.Requirement)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dependency %d: %w", i, err)
|
||||
}
|
||||
converted[i] = &proto.WorkspaceAgentScriptDependency{
|
||||
PrerequisiteResourceAddress: dependency.PrerequisiteResourceAddress,
|
||||
Requirement: requirement,
|
||||
}
|
||||
}
|
||||
return converted, nil
|
||||
}
|
||||
|
||||
func agentScriptDependencyRequirementFromProto(requirement proto.WorkspaceAgentScriptDependency_Requirement) (codersdk.WorkspaceAgentScriptDependencyRequirement, error) {
|
||||
switch requirement {
|
||||
case proto.WorkspaceAgentScriptDependency_REQUIREMENT_SUCCESS:
|
||||
return codersdk.WorkspaceAgentScriptDependencyRequirementSuccess, nil
|
||||
case proto.WorkspaceAgentScriptDependency_REQUIREMENT_COMPLETION:
|
||||
return codersdk.WorkspaceAgentScriptDependencyRequirementCompletion, nil
|
||||
default:
|
||||
return "", xerrors.Errorf("unsupported requirement %q", requirement)
|
||||
}
|
||||
}
|
||||
|
||||
func agentScriptDependencyRequirementToProto(requirement codersdk.WorkspaceAgentScriptDependencyRequirement) (proto.WorkspaceAgentScriptDependency_Requirement, error) {
|
||||
switch requirement {
|
||||
case codersdk.WorkspaceAgentScriptDependencyRequirementSuccess:
|
||||
return proto.WorkspaceAgentScriptDependency_REQUIREMENT_SUCCESS, nil
|
||||
case codersdk.WorkspaceAgentScriptDependencyRequirementCompletion:
|
||||
return proto.WorkspaceAgentScriptDependency_REQUIREMENT_COMPLETION, nil
|
||||
default:
|
||||
return proto.WorkspaceAgentScriptDependency_REQUIREMENT_UNSPECIFIED, xerrors.Errorf("unsupported requirement %q", requirement)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,17 @@ func TestManifest(t *testing.T) {
|
||||
StartBlocksLogin: true,
|
||||
Timeout: time.Second,
|
||||
DisplayName: "foo",
|
||||
ResourceAddress: "coder_script.foo",
|
||||
Dependencies: []codersdk.WorkspaceAgentScriptDependency{
|
||||
{
|
||||
PrerequisiteResourceAddress: "coder_script.bar",
|
||||
Requirement: codersdk.WorkspaceAgentScriptDependencyRequirementSuccess,
|
||||
},
|
||||
{
|
||||
PrerequisiteResourceAddress: "module.cache.coder_script.warm",
|
||||
Requirement: codersdk.WorkspaceAgentScriptDependencyRequirementCompletion,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: uuid.New(),
|
||||
@@ -129,6 +140,7 @@ func TestManifest(t *testing.T) {
|
||||
StartBlocksLogin: true,
|
||||
Timeout: time.Second * 4,
|
||||
DisplayName: "bar",
|
||||
ResourceAddress: "coder_script.bar",
|
||||
},
|
||||
},
|
||||
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
@@ -165,6 +177,65 @@ func TestManifest(t *testing.T) {
|
||||
require.Equal(t, manifest.Devcontainers, back.Devcontainers)
|
||||
}
|
||||
|
||||
func TestScriptDependencyConversionRejectsInvalidRequirements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, logSourceID := uuid.New(), uuid.New()
|
||||
|
||||
t.Run("FromProtoUnspecified", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := agentsdk.AgentScriptFromProto(&proto.WorkspaceAgentScript{
|
||||
Id: id[:],
|
||||
LogSourceId: logSourceID[:],
|
||||
ResourceAddress: "coder_script.install_tools",
|
||||
Dependencies: []*proto.WorkspaceAgentScriptDependency{{
|
||||
PrerequisiteResourceAddress: "coder_script.clone_repo",
|
||||
Requirement: proto.WorkspaceAgentScriptDependency_REQUIREMENT_UNSPECIFIED,
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, "dependency 0: unsupported requirement")
|
||||
})
|
||||
|
||||
t.Run("FromProtoEmptyAddress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := agentsdk.AgentScriptFromProto(&proto.WorkspaceAgentScript{
|
||||
Id: id[:],
|
||||
LogSourceId: logSourceID[:],
|
||||
ResourceAddress: "coder_script.install_tools",
|
||||
Dependencies: []*proto.WorkspaceAgentScriptDependency{{
|
||||
Requirement: proto.WorkspaceAgentScriptDependency_REQUIREMENT_SUCCESS,
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, "dependency 0 has an empty prerequisite resource address")
|
||||
})
|
||||
|
||||
t.Run("ToProtoUnknownString", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := agentsdk.ProtoFromScript(codersdk.WorkspaceAgentScript{
|
||||
ID: id,
|
||||
LogSourceID: logSourceID,
|
||||
ResourceAddress: "coder_script.install_tools",
|
||||
Dependencies: []codersdk.WorkspaceAgentScriptDependency{{
|
||||
PrerequisiteResourceAddress: "coder_script.clone_repo",
|
||||
Requirement: codersdk.WorkspaceAgentScriptDependencyRequirement("always"),
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, `dependency 0: unsupported requirement "always"`)
|
||||
})
|
||||
|
||||
t.Run("ToProtoEmptyAddress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := agentsdk.ProtoFromScript(codersdk.WorkspaceAgentScript{
|
||||
ID: id,
|
||||
LogSourceID: logSourceID,
|
||||
Dependencies: []codersdk.WorkspaceAgentScriptDependency{{
|
||||
Requirement: codersdk.WorkspaceAgentScriptDependencyRequirementSuccess,
|
||||
}},
|
||||
})
|
||||
require.ErrorContains(t, err, "dependency 0 has an empty prerequisite resource address")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSubsystems(t *testing.T) {
|
||||
t.Parallel()
|
||||
ss := []codersdk.AgentSubsystem{
|
||||
|
||||
@@ -94,9 +94,19 @@ import (
|
||||
// API v2.13:
|
||||
// - Added app_name and connection_method to Connection on the Agent API.
|
||||
// Agents no longer send type.
|
||||
//
|
||||
// API v2.14:
|
||||
// - Added resource_address (field 11) and dependencies (field 12) to
|
||||
// WorkspaceAgentScript on the Agent API, with the
|
||||
// WorkspaceAgentScriptDependency message. They carry each script's
|
||||
// Terraform address and its resolved coder_script_order prerequisites
|
||||
// in the manifest. Both are empty for unordered scripts.
|
||||
// - A v2.14 agent decodes these fields but does not yet enforce the
|
||||
// ordering, so a version check alone does not tell coderd that an agent
|
||||
// orders its scripts.
|
||||
const (
|
||||
CurrentMajor = 2
|
||||
CurrentMinor = 13
|
||||
CurrentMinor = 14
|
||||
)
|
||||
|
||||
var CurrentVersion = apiversion.New(CurrentMajor, CurrentMinor)
|
||||
|
||||
Reference in New Issue
Block a user