Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3a49ffa4 |
@@ -1405,6 +1405,14 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques
|
||||
httperror.WriteResponseError(ctx, w, err)
|
||||
return
|
||||
}
|
||||
if dc.SubagentID.Valid {
|
||||
api.mu.Unlock()
|
||||
httpapi.Write(ctx, w, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Cannot rebuild Terraform-defined devcontainer",
|
||||
Detail: fmt.Sprintf("Devcontainer %q has resources defined in Terraform and cannot be rebuilt from the UI. Update the workspace template to modify this devcontainer.", dc.Name),
|
||||
})
|
||||
return
|
||||
}
|
||||
if dc.Status.Transitioning() {
|
||||
api.mu.Unlock()
|
||||
|
||||
@@ -1627,16 +1635,25 @@ func (api *API) cleanupSubAgents(ctx context.Context) error {
|
||||
api.mu.Lock()
|
||||
defer api.mu.Unlock()
|
||||
|
||||
injected := make(map[uuid.UUID]bool, len(api.injectedSubAgentProcs))
|
||||
// Collect all subagent IDs that should be kept:
|
||||
// 1. Subagents currently tracked by injectedSubAgentProcs
|
||||
// 2. Subagents referenced by known devcontainers from the manifest
|
||||
keep := make(map[uuid.UUID]bool, len(api.injectedSubAgentProcs)+len(api.knownDevcontainers))
|
||||
for _, proc := range api.injectedSubAgentProcs {
|
||||
injected[proc.agent.ID] = true
|
||||
keep[proc.agent.ID] = true
|
||||
}
|
||||
for _, dc := range api.knownDevcontainers {
|
||||
if dc.SubagentID.Valid {
|
||||
keep[dc.SubagentID.UUID] = true
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
var errs []error
|
||||
for _, agent := range agents {
|
||||
if injected[agent.ID] {
|
||||
if keep[agent.ID] {
|
||||
continue
|
||||
}
|
||||
client := *api.subAgentClient.Load()
|
||||
@@ -1647,10 +1664,11 @@ func (api *API) cleanupSubAgents(ctx context.Context) error {
|
||||
slog.F("agent_id", agent.ID),
|
||||
slog.F("agent_name", agent.Name),
|
||||
)
|
||||
errs = append(errs, xerrors.Errorf("delete agent %s (%s): %w", agent.Name, agent.ID, err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// maybeInjectSubAgentIntoContainerLocked injects a subagent into a dev
|
||||
@@ -2001,7 +2019,11 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
|
||||
// logger.Warn(ctx, "set CAP_NET_ADMIN on agent binary failed", slog.Error(err))
|
||||
// }
|
||||
|
||||
deleteSubAgent := proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig)
|
||||
// Only delete and recreate subagents that were dynamically created
|
||||
// (ID == uuid.Nil). Terraform-defined subagents (subAgentConfig.ID !=
|
||||
// uuid.Nil) must not be deleted because they have attached resources
|
||||
// managed by terraform.
|
||||
deleteSubAgent := subAgentConfig.ID == uuid.Nil && proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig)
|
||||
if deleteSubAgent {
|
||||
logger.Debug(ctx, "deleting existing subagent for recreation", slog.F("agent_id", proc.agent.ID))
|
||||
client := *api.subAgentClient.Load()
|
||||
|
||||
@@ -437,7 +437,11 @@ func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.S
|
||||
}
|
||||
}
|
||||
|
||||
agent.ID = uuid.New()
|
||||
// Only generate a new ID if one wasn't provided. Terraform-defined
|
||||
// subagents have pre-existing IDs that should be preserved.
|
||||
if agent.ID == uuid.Nil {
|
||||
agent.ID = uuid.New()
|
||||
}
|
||||
agent.AuthToken = uuid.New()
|
||||
if m.agents == nil {
|
||||
m.agents = make(map[uuid.UUID]agentcontainers.SubAgent)
|
||||
@@ -1035,6 +1039,30 @@ func TestAPI(t *testing.T) {
|
||||
wantStatus: []int{http.StatusAccepted, http.StatusConflict},
|
||||
wantBody: []string{"Devcontainer recreation initiated", "is currently starting and cannot be restarted"},
|
||||
},
|
||||
{
|
||||
name: "Terraform-defined devcontainer cannot be rebuilt",
|
||||
devcontainerID: devcontainerID1.String(),
|
||||
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
ID: devcontainerID1,
|
||||
Name: "test-devcontainer-terraform",
|
||||
WorkspaceFolder: workspaceFolder1,
|
||||
ConfigPath: configPath1,
|
||||
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
|
||||
Container: &devContainer1,
|
||||
SubagentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
},
|
||||
},
|
||||
lister: &fakeContainerCLI{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
|
||||
},
|
||||
arch: "<none>",
|
||||
},
|
||||
devcontainerCLI: &fakeDevcontainerCLI{},
|
||||
wantStatus: []int{http.StatusForbidden},
|
||||
wantBody: []string{"Cannot rebuild Terraform-defined devcontainer"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -24,10 +24,12 @@ type SubAgent struct {
|
||||
DisplayApps []codersdk.DisplayApp
|
||||
}
|
||||
|
||||
// CloneConfig makes a copy of SubAgent without ID and AuthToken. The
|
||||
// name is inherited from the devcontainer.
|
||||
// CloneConfig makes a copy of SubAgent using configuration from the
|
||||
// devcontainer. The ID is inherited from dc.SubagentID if present, and
|
||||
// the name is inherited from the devcontainer. AuthToken is not copied.
|
||||
func (s SubAgent) CloneConfig(dc codersdk.WorkspaceAgentDevcontainer) SubAgent {
|
||||
return SubAgent{
|
||||
ID: dc.SubagentID.UUID,
|
||||
Name: dc.Name,
|
||||
Directory: s.Directory,
|
||||
Architecture: s.Architecture,
|
||||
@@ -190,6 +192,11 @@ func (a *subAgentAPIClient) List(ctx context.Context) ([]SubAgent, error) {
|
||||
func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (_ SubAgent, err error) {
|
||||
a.logger.Debug(ctx, "creating sub agent", slog.F("name", agent.Name), slog.F("directory", agent.Directory))
|
||||
|
||||
var id []byte
|
||||
if agent.ID != uuid.Nil {
|
||||
id = agent.ID[:]
|
||||
}
|
||||
|
||||
displayApps := make([]agentproto.CreateSubAgentRequest_DisplayApp, 0, len(agent.DisplayApps))
|
||||
for _, displayApp := range agent.DisplayApps {
|
||||
var app agentproto.CreateSubAgentRequest_DisplayApp
|
||||
@@ -228,6 +235,7 @@ func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (_ SubAg
|
||||
OperatingSystem: agent.OperatingSystem,
|
||||
DisplayApps: displayApps,
|
||||
Apps: apps,
|
||||
Id: id,
|
||||
})
|
||||
if err != nil {
|
||||
return SubAgent{}, err
|
||||
|
||||
@@ -306,3 +306,102 @@ func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSubAgent_CloneConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("CopiesIDFromDevcontainer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
subAgent := agentcontainers.SubAgent{
|
||||
ID: uuid.New(),
|
||||
Name: "original-name",
|
||||
Directory: "/workspace",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
|
||||
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
|
||||
}
|
||||
expectedID := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
|
||||
dc := codersdk.WorkspaceAgentDevcontainer{
|
||||
Name: "devcontainer-name",
|
||||
SubagentID: uuid.NullUUID{UUID: expectedID, Valid: true},
|
||||
}
|
||||
|
||||
cloned := subAgent.CloneConfig(dc)
|
||||
|
||||
assert.Equal(t, expectedID, cloned.ID)
|
||||
assert.Equal(t, dc.Name, cloned.Name)
|
||||
assert.Equal(t, subAgent.Directory, cloned.Directory)
|
||||
assert.Equal(t, uuid.Nil, cloned.AuthToken, "AuthToken should not be copied")
|
||||
})
|
||||
|
||||
t.Run("HandlesNilSubagentID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
subAgent := agentcontainers.SubAgent{
|
||||
ID: uuid.New(),
|
||||
Name: "original-name",
|
||||
Directory: "/workspace",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}
|
||||
dc := codersdk.WorkspaceAgentDevcontainer{
|
||||
Name: "devcontainer-name",
|
||||
SubagentID: uuid.NullUUID{Valid: false},
|
||||
}
|
||||
|
||||
cloned := subAgent.CloneConfig(dc)
|
||||
|
||||
assert.Equal(t, uuid.Nil, cloned.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSubAgent_EqualConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("TrueWhenFieldsMatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := agentcontainers.SubAgent{
|
||||
ID: uuid.MustParse("550e8400-e29b-41d4-a716-446655440000"),
|
||||
Name: "test-agent",
|
||||
Directory: "/workspace",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
|
||||
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
|
||||
}
|
||||
// Different ID but same config fields.
|
||||
b := agentcontainers.SubAgent{
|
||||
ID: uuid.MustParse("660e8400-e29b-41d4-a716-446655440000"),
|
||||
Name: "test-agent",
|
||||
Directory: "/workspace",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
|
||||
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
|
||||
}
|
||||
|
||||
assert.True(t, a.EqualConfig(b), "EqualConfig compares config fields, not ID")
|
||||
})
|
||||
|
||||
t.Run("FalseWhenFieldsDiffer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := agentcontainers.SubAgent{
|
||||
Name: "test-agent",
|
||||
Directory: "/workspace",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}
|
||||
b := agentcontainers.SubAgent{
|
||||
Name: "different-name",
|
||||
Directory: "/workspace",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}
|
||||
|
||||
assert.False(t, a.EqualConfig(b))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,11 +249,17 @@ func dbAppToProto(dbApp database.WorkspaceApp, agent database.WorkspaceAgent, ow
|
||||
func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevcontainer) []*agentproto.WorkspaceAgentDevcontainer {
|
||||
ret := make([]*agentproto.WorkspaceAgentDevcontainer, len(devcontainers))
|
||||
for i, dc := range devcontainers {
|
||||
var subagentID []byte
|
||||
if dc.SubagentID.Valid {
|
||||
subagentID = dc.SubagentID.UUID[:]
|
||||
}
|
||||
|
||||
ret[i] = &agentproto.WorkspaceAgentDevcontainer{
|
||||
Id: dc.ID[:],
|
||||
Name: dc.Name,
|
||||
WorkspaceFolder: dc.WorkspaceFolder,
|
||||
ConfigPath: dc.ConfigPath,
|
||||
SubagentId: subagentID,
|
||||
}
|
||||
}
|
||||
return ret
|
||||
|
||||
+56
-19
@@ -37,25 +37,6 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
|
||||
//nolint:gocritic // This gives us only the permissions required to do the job.
|
||||
ctx = dbauthz.AsSubAgentAPI(ctx, a.OrganizationID, a.OwnerID)
|
||||
|
||||
parentAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get parent agent: %w", err)
|
||||
}
|
||||
|
||||
agentName := req.Name
|
||||
if agentName == "" {
|
||||
return nil, codersdk.ValidationError{
|
||||
Field: "name",
|
||||
Detail: "agent name cannot be empty",
|
||||
}
|
||||
}
|
||||
if !provisioner.AgentNameRegex.MatchString(agentName) {
|
||||
return nil, codersdk.ValidationError{
|
||||
Field: "name",
|
||||
Detail: fmt.Sprintf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex),
|
||||
}
|
||||
}
|
||||
|
||||
createdAt := a.Clock.Now()
|
||||
|
||||
displayApps := make([]database.DisplayApp, 0, len(req.DisplayApps))
|
||||
@@ -83,6 +64,62 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
|
||||
displayApps = append(displayApps, app)
|
||||
}
|
||||
|
||||
parentAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get parent agent: %w", err)
|
||||
}
|
||||
|
||||
// An ID is only given in the request when it is a terraform-defined devcontainer
|
||||
// that has attached resources. These subagents are pre-provisioned by terraform
|
||||
// (the agent record already exists), so we update configurable fields like
|
||||
// display_apps rather than creating a new agent.
|
||||
if req.Id != nil {
|
||||
id, err := uuid.FromBytes(req.Id)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse id: %w", err)
|
||||
}
|
||||
|
||||
subAgent, err := a.Database.GetWorkspaceAgentByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace agent by id: %w", err)
|
||||
}
|
||||
|
||||
// Validate that the subagent belongs to the current parent agent to
|
||||
// prevent updating subagents from other agents within the same workspace.
|
||||
if !subAgent.ParentID.Valid || subAgent.ParentID.UUID != parentAgent.ID {
|
||||
return nil, xerrors.Errorf("subagent does not belong to this parent agent")
|
||||
}
|
||||
|
||||
if err := a.Database.UpdateWorkspaceAgentDisplayAppsByID(ctx, database.UpdateWorkspaceAgentDisplayAppsByIDParams{
|
||||
ID: id,
|
||||
DisplayApps: displayApps,
|
||||
UpdatedAt: createdAt,
|
||||
}); err != nil {
|
||||
return nil, xerrors.Errorf("update workspace agent display apps: %w", err)
|
||||
}
|
||||
|
||||
return &agentproto.CreateSubAgentResponse{
|
||||
Agent: &agentproto.SubAgent{
|
||||
Name: subAgent.Name,
|
||||
Id: subAgent.ID[:],
|
||||
AuthToken: subAgent.AuthToken[:],
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
agentName := req.Name
|
||||
if agentName == "" {
|
||||
return nil, codersdk.ValidationError{
|
||||
Field: "name",
|
||||
Detail: "agent name cannot be empty",
|
||||
}
|
||||
}
|
||||
if !provisioner.AgentNameRegex.MatchString(agentName) {
|
||||
return nil, codersdk.ValidationError{
|
||||
Field: "name",
|
||||
Detail: fmt.Sprintf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex),
|
||||
}
|
||||
}
|
||||
subAgent, err := a.Database.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{
|
||||
ID: uuid.New(),
|
||||
ParentID: uuid.NullUUID{Valid: true, UUID: parentAgent.ID},
|
||||
|
||||
@@ -1132,6 +1132,236 @@ func TestSubAgentAPI(t *testing.T) {
|
||||
require.Equal(t, "Custom App", apps[0].DisplayName)
|
||||
})
|
||||
|
||||
t.Run("CreateSubAgent_UpdateExisting", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK_UpdateDisplayApps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := testutil.Logger(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
clock := quartz.NewMock(t)
|
||||
|
||||
db, org := newDatabaseWithOrg(t)
|
||||
user, agent := newUserWithWorkspaceAgent(t, db, org)
|
||||
api := newAgentAPI(t, log, db, clock, user, org, agent)
|
||||
|
||||
// Given: An existing child agent with some display apps.
|
||||
childAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
|
||||
ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID},
|
||||
ResourceID: agent.ResourceID,
|
||||
Name: "existing-child-agent",
|
||||
Directory: "/workspaces/test",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
DisplayApps: []database.DisplayApp{database.DisplayAppVscode},
|
||||
})
|
||||
|
||||
// When: We call CreateSubAgent with the existing agent's ID and new display apps.
|
||||
createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
||||
Id: childAgent.ID[:],
|
||||
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
|
||||
proto.CreateSubAgentRequest_WEB_TERMINAL,
|
||||
proto.CreateSubAgentRequest_SSH_HELPER,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: The response contains the existing agent's details.
|
||||
require.NotNil(t, createResp.Agent)
|
||||
require.Equal(t, childAgent.Name, createResp.Agent.Name)
|
||||
require.Equal(t, childAgent.ID[:], createResp.Agent.Id)
|
||||
require.Equal(t, childAgent.AuthToken[:], createResp.Agent.AuthToken)
|
||||
|
||||
// And: The database agent's display apps are updated.
|
||||
updatedAgent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgent.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, updatedAgent.DisplayApps, 2)
|
||||
require.Contains(t, updatedAgent.DisplayApps, database.DisplayAppWebTerminal)
|
||||
require.Contains(t, updatedAgent.DisplayApps, database.DisplayAppSSHHelper)
|
||||
})
|
||||
|
||||
t.Run("Error_MalformedID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := testutil.Logger(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
clock := quartz.NewMock(t)
|
||||
|
||||
db, org := newDatabaseWithOrg(t)
|
||||
user, agent := newUserWithWorkspaceAgent(t, db, org)
|
||||
api := newAgentAPI(t, log, db, clock, user, org, agent)
|
||||
|
||||
// When: We call CreateSubAgent with malformed ID bytes (not 16 bytes).
|
||||
// uuid.FromBytes requires exactly 16 bytes, so we provide fewer.
|
||||
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
||||
Id: []byte("short"),
|
||||
})
|
||||
|
||||
// Then: We expect an error about parsing the ID.
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "parse id")
|
||||
})
|
||||
|
||||
t.Run("Error_AgentNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := testutil.Logger(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
clock := quartz.NewMock(t)
|
||||
|
||||
db, org := newDatabaseWithOrg(t)
|
||||
user, agent := newUserWithWorkspaceAgent(t, db, org)
|
||||
api := newAgentAPI(t, log, db, clock, user, org, agent)
|
||||
|
||||
// When: We call CreateSubAgent with a non-existent agent ID.
|
||||
nonExistentID := uuid.New()
|
||||
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
||||
Id: nonExistentID[:],
|
||||
})
|
||||
|
||||
// Then: We expect an error about the agent not being found.
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "get workspace agent by id")
|
||||
})
|
||||
|
||||
t.Run("Error_ParentMismatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := testutil.Logger(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
clock := quartz.NewMock(t)
|
||||
|
||||
db, org := newDatabaseWithOrg(t)
|
||||
user, agent := newUserWithWorkspaceAgent(t, db, org)
|
||||
api := newAgentAPI(t, log, db, clock, user, org, agent)
|
||||
|
||||
// Create a second agent (sibling) within the same workspace/resource.
|
||||
// This sibling has a different parent ID (or no parent).
|
||||
siblingAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
|
||||
ParentID: uuid.NullUUID{Valid: false}, // No parent - it's a top-level agent
|
||||
ResourceID: agent.ResourceID,
|
||||
Name: "sibling-agent",
|
||||
Directory: "/workspaces/sibling",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
})
|
||||
|
||||
// Create a child of the sibling agent (not our agent).
|
||||
childOfSibling := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
|
||||
ParentID: uuid.NullUUID{Valid: true, UUID: siblingAgent.ID},
|
||||
ResourceID: agent.ResourceID,
|
||||
Name: "child-of-sibling",
|
||||
Directory: "/workspaces/test",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
})
|
||||
|
||||
// When: Our API (which is for `agent`) tries to update the child of `siblingAgent`.
|
||||
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
||||
Id: childOfSibling.ID[:],
|
||||
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
|
||||
proto.CreateSubAgentRequest_VSCODE,
|
||||
},
|
||||
})
|
||||
|
||||
// Then: We expect an error about the parent mismatch.
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "subagent does not belong to this parent agent")
|
||||
})
|
||||
|
||||
t.Run("OK_OtherFieldsNotModified", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := testutil.Logger(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
clock := quartz.NewMock(t)
|
||||
|
||||
db, org := newDatabaseWithOrg(t)
|
||||
user, agent := newUserWithWorkspaceAgent(t, db, org)
|
||||
api := newAgentAPI(t, log, db, clock, user, org, agent)
|
||||
|
||||
// Given: An existing child agent with specific properties.
|
||||
originalName := "original-child-agent"
|
||||
originalDir := "/workspaces/original"
|
||||
originalArch := "amd64"
|
||||
originalOS := "linux"
|
||||
|
||||
childAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
|
||||
ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID},
|
||||
ResourceID: agent.ResourceID,
|
||||
Name: originalName,
|
||||
Directory: originalDir,
|
||||
Architecture: originalArch,
|
||||
OperatingSystem: originalOS,
|
||||
DisplayApps: []database.DisplayApp{database.DisplayAppVscode},
|
||||
})
|
||||
|
||||
// When: We call CreateSubAgent with different values for name, directory, arch, and OS.
|
||||
createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
||||
Id: childAgent.ID[:],
|
||||
Name: "different-name",
|
||||
Directory: "/different/path",
|
||||
Architecture: "arm64",
|
||||
OperatingSystem: "darwin",
|
||||
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
|
||||
proto.CreateSubAgentRequest_WEB_TERMINAL,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: The response contains the original agent name, not the new one.
|
||||
require.NotNil(t, createResp.Agent)
|
||||
require.Equal(t, originalName, createResp.Agent.Name)
|
||||
|
||||
// And: The database agent's other fields are unchanged.
|
||||
updatedAgent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgent.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, originalName, updatedAgent.Name)
|
||||
require.Equal(t, originalDir, updatedAgent.Directory)
|
||||
require.Equal(t, originalArch, updatedAgent.Architecture)
|
||||
require.Equal(t, originalOS, updatedAgent.OperatingSystem)
|
||||
|
||||
// But display apps should be updated.
|
||||
require.Len(t, updatedAgent.DisplayApps, 1)
|
||||
require.Equal(t, database.DisplayAppWebTerminal, updatedAgent.DisplayApps[0])
|
||||
})
|
||||
|
||||
t.Run("Error_NoParentID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := testutil.Logger(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
clock := quartz.NewMock(t)
|
||||
|
||||
db, org := newDatabaseWithOrg(t)
|
||||
user, agent := newUserWithWorkspaceAgent(t, db, org)
|
||||
api := newAgentAPI(t, log, db, clock, user, org, agent)
|
||||
|
||||
// Given: An agent without a parent (a top-level agent).
|
||||
topLevelAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
|
||||
ParentID: uuid.NullUUID{Valid: false}, // No parent
|
||||
ResourceID: agent.ResourceID,
|
||||
Name: "top-level-agent",
|
||||
Directory: "/workspaces/test",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
})
|
||||
|
||||
// When: We try to update this agent as if it were a subagent.
|
||||
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
||||
Id: topLevelAgent.ID[:],
|
||||
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
|
||||
proto.CreateSubAgentRequest_VSCODE,
|
||||
},
|
||||
})
|
||||
|
||||
// Then: We expect an error because the agent has no parent.
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "subagent does not belong to this parent agent")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ListSubAgents", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Generated
+8
@@ -20774,6 +20774,14 @@ const docTemplate = `{
|
||||
}
|
||||
]
|
||||
},
|
||||
"subagent_id": {
|
||||
"format": "uuid",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/uuid.NullUUID"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace_folder": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
Generated
+8
@@ -19082,6 +19082,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"subagent_id": {
|
||||
"format": "uuid",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/uuid.NullUUID"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace_folder": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -5726,6 +5726,19 @@ func (q *querier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg da
|
||||
return q.db.UpdateWorkspaceAgentConnectionByID(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg database.UpdateWorkspaceAgentDisplayAppsByIDParams) error {
|
||||
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, workspace); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return q.db.UpdateWorkspaceAgentDisplayAppsByID(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg database.UpdateWorkspaceAgentLifecycleStateByIDParams) error {
|
||||
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -1930,6 +1930,17 @@ func (s *MethodTestSuite) TestWorkspace() {
|
||||
dbm.EXPECT().UpdateWorkspaceAgentStartupByID(gomock.Any(), arg).Return(nil).AnyTimes()
|
||||
check.Args(arg).Asserts(w, policy.ActionUpdate).Returns()
|
||||
}))
|
||||
s.Run("UpdateWorkspaceAgentDisplayAppsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
w := testutil.Fake(s.T(), faker, database.Workspace{})
|
||||
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
|
||||
arg := database.UpdateWorkspaceAgentDisplayAppsByIDParams{
|
||||
ID: agt.ID,
|
||||
DisplayApps: []database.DisplayApp{database.DisplayAppVscode},
|
||||
}
|
||||
dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdateWorkspaceAgentDisplayAppsByID(gomock.Any(), arg).Return(nil).AnyTimes()
|
||||
check.Args(arg).Asserts(w, policy.ActionUpdate).Returns()
|
||||
}))
|
||||
s.Run("GetWorkspaceAgentLogsAfter", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
ws := testutil.Fake(s.T(), faker, database.Workspace{})
|
||||
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
|
||||
|
||||
@@ -3909,6 +3909,14 @@ func (m queryMetricsStore) UpdateWorkspaceAgentConnectionByID(ctx context.Contex
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg database.UpdateWorkspaceAgentDisplayAppsByIDParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpdateWorkspaceAgentDisplayAppsByID(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateWorkspaceAgentDisplayAppsByID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateWorkspaceAgentDisplayAppsByID").Inc()
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg database.UpdateWorkspaceAgentLifecycleStateByIDParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpdateWorkspaceAgentLifecycleStateByID(ctx, arg)
|
||||
|
||||
@@ -7321,6 +7321,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAgentConnectionByID(ctx, arg any
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAgentConnectionByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAgentConnectionByID), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateWorkspaceAgentDisplayAppsByID mocks base method.
|
||||
func (m *MockStore) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg database.UpdateWorkspaceAgentDisplayAppsByIDParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateWorkspaceAgentDisplayAppsByID", ctx, arg)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpdateWorkspaceAgentDisplayAppsByID indicates an expected call of UpdateWorkspaceAgentDisplayAppsByID.
|
||||
func (mr *MockStoreMockRecorder) UpdateWorkspaceAgentDisplayAppsByID(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAgentDisplayAppsByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAgentDisplayAppsByID), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateWorkspaceAgentLifecycleStateByID mocks base method.
|
||||
func (m *MockStore) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg database.UpdateWorkspaceAgentLifecycleStateByIDParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -738,6 +738,7 @@ type sqlcQuerier interface {
|
||||
UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (WorkspaceTable, error)
|
||||
UpdateWorkspaceACLByID(ctx context.Context, arg UpdateWorkspaceACLByIDParams) error
|
||||
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
|
||||
UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg UpdateWorkspaceAgentDisplayAppsByIDParams) error
|
||||
UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error
|
||||
UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentLogOverflowByIDParams) error
|
||||
UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error
|
||||
|
||||
@@ -19306,6 +19306,26 @@ func (q *sqlQuerier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceAgentDisplayAppsByID = `-- name: UpdateWorkspaceAgentDisplayAppsByID :exec
|
||||
UPDATE
|
||||
workspace_agents
|
||||
SET
|
||||
display_apps = $2, updated_at = $3
|
||||
WHERE
|
||||
id = $1
|
||||
`
|
||||
|
||||
type UpdateWorkspaceAgentDisplayAppsByIDParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
DisplayApps []DisplayApp `db:"display_apps" json:"display_apps"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg UpdateWorkspaceAgentDisplayAppsByIDParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateWorkspaceAgentDisplayAppsByID, arg.ID, pq.Array(arg.DisplayApps), arg.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceAgentLifecycleStateByID = `-- name: UpdateWorkspaceAgentLifecycleStateByID :exec
|
||||
UPDATE
|
||||
workspace_agents
|
||||
|
||||
@@ -180,6 +180,14 @@ SET
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: UpdateWorkspaceAgentDisplayAppsByID :exec
|
||||
UPDATE
|
||||
workspace_agents
|
||||
SET
|
||||
display_apps = $2, updated_at = $3
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: GetWorkspaceAgentLogsAfter :many
|
||||
SELECT
|
||||
*
|
||||
|
||||
@@ -425,11 +425,20 @@ func DevcontainerFromProto(pdc *proto.WorkspaceAgentDevcontainer) (codersdk.Work
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse id: %w", err)
|
||||
}
|
||||
var subagentID uuid.NullUUID
|
||||
if pdc.SubagentId != nil {
|
||||
subagentID.Valid = true
|
||||
subagentID.UUID, err = uuid.FromBytes(pdc.SubagentId)
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse subagent id: %w", err)
|
||||
}
|
||||
}
|
||||
return codersdk.WorkspaceAgentDevcontainer{
|
||||
ID: id,
|
||||
Name: pdc.Name,
|
||||
WorkspaceFolder: pdc.WorkspaceFolder,
|
||||
ConfigPath: pdc.ConfigPath,
|
||||
SubagentID: subagentID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -442,10 +451,16 @@ func ProtoFromDevcontainers(dcs []codersdk.WorkspaceAgentDevcontainer) []*proto.
|
||||
}
|
||||
|
||||
func ProtoFromDevcontainer(dc codersdk.WorkspaceAgentDevcontainer) *proto.WorkspaceAgentDevcontainer {
|
||||
var subagentID []byte
|
||||
if dc.SubagentID.Valid {
|
||||
subagentID = dc.SubagentID.UUID[:]
|
||||
}
|
||||
|
||||
return &proto.WorkspaceAgentDevcontainer{
|
||||
Id: dc.ID[:],
|
||||
Name: dc.Name,
|
||||
WorkspaceFolder: dc.WorkspaceFolder,
|
||||
ConfigPath: dc.ConfigPath,
|
||||
SubagentId: subagentID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ func TestManifest(t *testing.T) {
|
||||
ID: uuid.New(),
|
||||
WorkspaceFolder: "/home/coder/coder",
|
||||
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
|
||||
SubagentID: uuid.NullUUID{Valid: true, UUID: uuid.New()},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -440,10 +440,11 @@ func (s WorkspaceAgentDevcontainerStatus) Transitioning() bool {
|
||||
// WorkspaceAgentDevcontainer defines the location of a devcontainer
|
||||
// configuration in a workspace that is visible to the workspace agent.
|
||||
type WorkspaceAgentDevcontainer struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Name string `json:"name"`
|
||||
WorkspaceFolder string `json:"workspace_folder"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Name string `json:"name"`
|
||||
WorkspaceFolder string `json:"workspace_folder"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
SubagentID uuid.NullUUID `json:"subagent_id,omitempty" format:"uuid"`
|
||||
|
||||
// Additional runtime fields.
|
||||
Status WorkspaceAgentDevcontainerStatus `json:"status"`
|
||||
@@ -458,6 +459,7 @@ func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) boo
|
||||
return d.ID == other.ID &&
|
||||
d.Name == other.Name &&
|
||||
d.WorkspaceFolder == other.WorkspaceFolder &&
|
||||
d.SubagentID == other.SubagentID &&
|
||||
d.Status == other.Status &&
|
||||
d.Dirty == other.Dirty &&
|
||||
(d.Container == nil && other.Container == nil ||
|
||||
@@ -467,6 +469,12 @@ func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) boo
|
||||
d.Error == other.Error
|
||||
}
|
||||
|
||||
// IsTerraformDefined returns true if this devcontainer has resources defined
|
||||
// in Terraform.
|
||||
func (d WorkspaceAgentDevcontainer) IsTerraformDefined() bool {
|
||||
return d.SubagentID.Valid
|
||||
}
|
||||
|
||||
// WorkspaceAgentDevcontainerAgent represents the sub agent for a
|
||||
// devcontainer.
|
||||
type WorkspaceAgentDevcontainerAgent struct {
|
||||
|
||||
@@ -110,3 +110,173 @@ func TestWorkspaceAgentLogTextSpecialChars(t *testing.T) {
|
||||
result := log.Text("main", "startup_script")
|
||||
require.Equal(t, "2024-01-28T10:30:00Z [debug] [agent.main|startup_script] \033[31mError!\033[0m 🚀 Unicode: 日本語", result)
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentDevcontainerEquals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseID := uuid.New()
|
||||
subagentID := uuid.New()
|
||||
containerID := "container-123"
|
||||
agentID := uuid.New()
|
||||
|
||||
base := codersdk.WorkspaceAgentDevcontainer{
|
||||
ID: baseID,
|
||||
Name: "test-dc",
|
||||
WorkspaceFolder: "/workspace",
|
||||
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
|
||||
Dirty: false,
|
||||
Container: &codersdk.WorkspaceAgentContainer{ID: containerID},
|
||||
Agent: &codersdk.WorkspaceAgentDevcontainerAgent{ID: agentID, Name: "agent-1"},
|
||||
Error: "",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modify func(codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer
|
||||
expectEqual bool
|
||||
}{
|
||||
{
|
||||
name: "identical",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer { return d },
|
||||
expectEqual: true,
|
||||
},
|
||||
{
|
||||
name: "different ID",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.ID = uuid.New()
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different Name",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.Name = "other-dc"
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different WorkspaceFolder",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.WorkspaceFolder = "/other"
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different SubagentID (one valid, one nil)",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.SubagentID = uuid.NullUUID{Valid: true, UUID: subagentID}
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different SubagentID UUIDs",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.SubagentID = uuid.NullUUID{Valid: true, UUID: uuid.New()}
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different Status",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.Status = codersdk.WorkspaceAgentDevcontainerStatusStopped
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different Dirty",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.Dirty = true
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different Container (one nil)",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.Container = nil
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different Container IDs",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.Container = &codersdk.WorkspaceAgentContainer{ID: "different-container"}
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different Agent (one nil)",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.Agent = nil
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different Agent values",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.Agent = &codersdk.WorkspaceAgentDevcontainerAgent{ID: agentID, Name: "agent-2"}
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
{
|
||||
name: "different Error",
|
||||
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
d.Error = "some error"
|
||||
return d
|
||||
},
|
||||
expectEqual: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
other := tt.modify(base)
|
||||
require.Equal(t, tt.expectEqual, base.Equals(other))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentDevcontainerIsTerraformDefined(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
subagentID uuid.NullUUID
|
||||
expectIsTerraformDefined bool
|
||||
}{
|
||||
{
|
||||
name: "false when SubagentID is not valid",
|
||||
subagentID: uuid.NullUUID{},
|
||||
expectIsTerraformDefined: false,
|
||||
},
|
||||
{
|
||||
name: "true when SubagentID is valid",
|
||||
subagentID: uuid.NullUUID{Valid: true, UUID: uuid.New()},
|
||||
expectIsTerraformDefined: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dc := codersdk.WorkspaceAgentDevcontainer{
|
||||
ID: uuid.New(),
|
||||
Name: "test-dc",
|
||||
WorkspaceFolder: "/workspace",
|
||||
SubagentID: tt.subagentID,
|
||||
}
|
||||
require.Equal(t, tt.expectIsTerraformDefined, dc.IsTerraformDefined())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+8
@@ -838,6 +838,10 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"status": "running",
|
||||
"subagent_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"workspace_folder": "string"
|
||||
}
|
||||
],
|
||||
@@ -1015,6 +1019,10 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"status": "running",
|
||||
"subagent_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"workspace_folder": "string"
|
||||
}
|
||||
],
|
||||
|
||||
Generated
+9
@@ -10514,6 +10514,10 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"status": "running",
|
||||
"subagent_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"workspace_folder": "string"
|
||||
}
|
||||
```
|
||||
@@ -10530,6 +10534,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
| `id` | string | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `status` | [codersdk.WorkspaceAgentDevcontainerStatus](#codersdkworkspaceagentdevcontainerstatus) | false | | Additional runtime fields. |
|
||||
| `subagent_id` | [uuid.NullUUID](#uuidnulluuid) | false | | |
|
||||
| `workspace_folder` | string | false | | |
|
||||
|
||||
## codersdk.WorkspaceAgentDevcontainerAgent
|
||||
@@ -10661,6 +10666,10 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"status": "running",
|
||||
"subagent_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"workspace_folder": "string"
|
||||
}
|
||||
],
|
||||
|
||||
Generated
+1
@@ -6290,6 +6290,7 @@ export interface WorkspaceAgentDevcontainer {
|
||||
readonly name: string;
|
||||
readonly workspace_folder: string;
|
||||
readonly config_path?: string;
|
||||
readonly subagent_id?: string;
|
||||
/**
|
||||
* Additional runtime fields.
|
||||
*/
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import { getPreferredProxy } from "contexts/ProxyContext";
|
||||
import { spyOn, userEvent, within } from "storybook/test";
|
||||
import { screen, spyOn, userEvent, within } from "storybook/test";
|
||||
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
|
||||
|
||||
const meta: Meta<typeof AgentDevcontainerCard> = {
|
||||
@@ -185,6 +185,37 @@ export const WithPortForwarding: Story = {
|
||||
],
|
||||
};
|
||||
|
||||
export const PrecreatedSubAgent: Story = {
|
||||
args: {
|
||||
devcontainer: {
|
||||
...MockWorkspaceAgentDevcontainer,
|
||||
subagent_id: "precreated-subagent-id",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const trigger = canvas.getByTestId("precreated-subagent-rebuild-trigger");
|
||||
await userEvent.hover(trigger);
|
||||
await screen.findByRole("tooltip");
|
||||
},
|
||||
};
|
||||
|
||||
export const PrecreatedSubAgentDirty: Story = {
|
||||
args: {
|
||||
devcontainer: {
|
||||
...MockWorkspaceAgentDevcontainer,
|
||||
subagent_id: "precreated-subagent-id",
|
||||
dirty: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const outdatedStatus = canvas.getByText("Outdated");
|
||||
await userEvent.hover(outdatedStatus);
|
||||
await screen.findByRole("tooltip");
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDeleteError: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "deleteDevContainer").mockRejectedValue(
|
||||
|
||||
@@ -42,6 +42,7 @@ import { AgentButton } from "./AgentButton";
|
||||
import { AgentDevcontainerMoreActions } from "./AgentDevcontainerMoreActions";
|
||||
import { AgentLatency } from "./AgentLatency";
|
||||
import { DevcontainerStatus } from "./AgentStatus";
|
||||
import { isTerraformDefined } from "./devcontainerUtils";
|
||||
import { PortForwardButton } from "./PortForwardButton";
|
||||
import { AgentSSHButton } from "./SSHButton/SSHButton";
|
||||
import { SubAgentOutdatedTooltip } from "./SubAgentOutdatedTooltip";
|
||||
@@ -162,6 +163,8 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
|
||||
const showSubAgentAppsPlaceholders =
|
||||
devcontainer.status === "starting" || subAgent?.status === "connecting";
|
||||
|
||||
const hasPrecreatedSubagent = isTerraformDefined(devcontainer);
|
||||
|
||||
const handleRebuildDevcontainer = () => {
|
||||
rebuildDevcontainerMutation.mutate();
|
||||
};
|
||||
@@ -232,16 +235,31 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRebuildDevcontainer}
|
||||
disabled={isTransitioning}
|
||||
>
|
||||
<Spinner loading={isTransitioning} />
|
||||
|
||||
{rebuildButtonLabel(devcontainer)}
|
||||
</Button>
|
||||
{hasPrecreatedSubagent ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span data-testid="precreated-subagent-rebuild-trigger">
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
{rebuildButtonLabel(devcontainer)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
This dev container is defined in Terraform and cannot be rebuilt
|
||||
from the UI.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRebuildDevcontainer}
|
||||
disabled={isTransitioning}
|
||||
>
|
||||
<Spinner loading={isTransitioning} />
|
||||
{rebuildButtonLabel(devcontainer)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showDevcontainerControls && displayApps.includes("ssh_helper") && (
|
||||
<AgentSSHButton
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { TooltipTrigger } from "components/Tooltip/Tooltip";
|
||||
import { RotateCcwIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { isTerraformDefined } from "./devcontainerUtils";
|
||||
|
||||
type SubAgentOutdatedTooltipProps = {
|
||||
devcontainer: WorkspaceAgentDevcontainer;
|
||||
@@ -33,9 +33,13 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasPrecreatedSubagent = isTerraformDefined(devcontainer);
|
||||
|
||||
const title = "Dev Container Outdated";
|
||||
const opener = "This Dev Container is outdated.";
|
||||
const text = `${opener} This can happen if you modify your devcontainer.json file after the Dev Container has been created. To fix this, you can rebuild the Dev Container.`;
|
||||
const text = hasPrecreatedSubagent
|
||||
? `${opener} This dev container is managed by your template. Update the template to apply changes.`
|
||||
: `${opener} This can happen if you modify your devcontainer.json file after the Dev Container has been created. To fix this, you can rebuild the Dev Container.`;
|
||||
|
||||
return (
|
||||
<HelpTooltip>
|
||||
@@ -45,22 +49,24 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
<Stack spacing={1}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<HelpTooltipTitle>{title}</HelpTooltipTitle>
|
||||
<HelpTooltipText>{text}</HelpTooltipText>
|
||||
</div>
|
||||
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipAction
|
||||
icon={RotateCcwIcon}
|
||||
onClick={onUpdate}
|
||||
ariaLabel="Rebuild Dev Container"
|
||||
>
|
||||
Rebuild Dev Container
|
||||
</HelpTooltipAction>
|
||||
</HelpTooltipLinksGroup>
|
||||
</Stack>
|
||||
{!hasPrecreatedSubagent && (
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipAction
|
||||
icon={RotateCcwIcon}
|
||||
onClick={onUpdate}
|
||||
ariaLabel="Rebuild Dev Container"
|
||||
>
|
||||
Rebuild Dev Container
|
||||
</HelpTooltipAction>
|
||||
</HelpTooltipLinksGroup>
|
||||
)}
|
||||
</div>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { WorkspaceAgentDevcontainer } from "api/typesGenerated";
|
||||
|
||||
/**
|
||||
* Returns true if this devcontainer has resources defined in Terraform.
|
||||
*/
|
||||
export function isTerraformDefined(
|
||||
devcontainer: WorkspaceAgentDevcontainer,
|
||||
): boolean {
|
||||
return Boolean(devcontainer.subagent_id);
|
||||
}
|
||||
Reference in New Issue
Block a user