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:
Bobby Ho
2026-10-14 17:26:31 +00:00
committed by GitHub
parent fb09983c77
commit ee5e3a4029
13 changed files with 2052 additions and 1411 deletions
+4 -4
View File
@@ -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
View File
@@ -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
}
+7
View File
@@ -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,
) {
+1567 -1394
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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 {
+6
View File
@@ -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
}
+78
View File
@@ -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:]
}
}
+50 -5
View File
@@ -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)
}
}
+123
View File
@@ -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()
+13
View File
@@ -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
+96 -5
View File
@@ -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)
}
}
+71
View File
@@ -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{
+11 -1
View File
@@ -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)