mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat(coderd/agentapi): allow inserting apps for sub agents (#18129)
Allow creating workspace apps for a sub agent when the agent is being created with `CreateSubAgent` in the agent api.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -388,10 +388,52 @@ message CreateSubAgentRequest {
|
|||||||
string directory = 2;
|
string directory = 2;
|
||||||
string architecture = 3;
|
string architecture = 3;
|
||||||
string operating_system = 4;
|
string operating_system = 4;
|
||||||
|
|
||||||
|
message App {
|
||||||
|
message Healthcheck {
|
||||||
|
int32 interval = 1;
|
||||||
|
int32 threshold = 2;
|
||||||
|
string url = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OpenIn {
|
||||||
|
SLIM_WINDOW = 0;
|
||||||
|
TAB = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Share {
|
||||||
|
OWNER = 0;
|
||||||
|
AUTHENTICATED = 1;
|
||||||
|
PUBLIC = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string slug = 1;
|
||||||
|
optional string command = 2;
|
||||||
|
optional string display_name = 3;
|
||||||
|
optional bool external = 4;
|
||||||
|
optional string group = 5;
|
||||||
|
optional Healthcheck healthcheck = 6;
|
||||||
|
optional bool hidden = 7;
|
||||||
|
optional string icon = 8;
|
||||||
|
optional OpenIn open_in = 9;
|
||||||
|
optional int32 order = 10;
|
||||||
|
optional Share share = 11;
|
||||||
|
optional bool subdomain = 12;
|
||||||
|
optional string url = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
repeated App apps = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateSubAgentResponse {
|
message CreateSubAgentResponse {
|
||||||
|
message AppCreationError {
|
||||||
|
int32 index = 1;
|
||||||
|
optional string field = 2;
|
||||||
|
string error = 3;
|
||||||
|
}
|
||||||
|
|
||||||
SubAgent agent = 1;
|
SubAgent agent = 1;
|
||||||
|
repeated AppCreationError app_creation_errors = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message DeleteSubAgentRequest {
|
message DeleteSubAgentRequest {
|
||||||
|
@ -2,6 +2,9 @@ package agentapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/sqlc-dev/pqtype"
|
"github.com/sqlc-dev/pqtype"
|
||||||
@ -11,6 +14,7 @@ import (
|
|||||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||||
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/coder/v2/provisioner"
|
"github.com/coder/coder/v2/provisioner"
|
||||||
"github.com/coder/quartz"
|
"github.com/coder/quartz"
|
||||||
)
|
)
|
||||||
@ -37,10 +41,16 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
|
|||||||
|
|
||||||
agentName := req.Name
|
agentName := req.Name
|
||||||
if agentName == "" {
|
if agentName == "" {
|
||||||
return nil, xerrors.Errorf("agent name cannot be empty")
|
return nil, codersdk.ValidationError{
|
||||||
|
Field: "name",
|
||||||
|
Detail: "agent name cannot be empty",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !provisioner.AgentNameRegex.MatchString(agentName) {
|
if !provisioner.AgentNameRegex.MatchString(agentName) {
|
||||||
return nil, xerrors.Errorf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex.String())
|
return nil, codersdk.ValidationError{
|
||||||
|
Field: "name",
|
||||||
|
Detail: fmt.Sprintf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createdAt := a.Clock.Now()
|
createdAt := a.Clock.Now()
|
||||||
@ -71,12 +81,127 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
|
|||||||
return nil, xerrors.Errorf("insert sub agent: %w", err)
|
return nil, xerrors.Errorf("insert sub agent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var appCreationErrors []*agentproto.CreateSubAgentResponse_AppCreationError
|
||||||
|
appSlugs := make(map[string]struct{})
|
||||||
|
|
||||||
|
for i, app := range req.Apps {
|
||||||
|
err := func() error {
|
||||||
|
slug := app.Slug
|
||||||
|
if slug == "" {
|
||||||
|
return codersdk.ValidationError{
|
||||||
|
Field: "slug",
|
||||||
|
Detail: "must not be empty",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !provisioner.AppSlugRegex.MatchString(slug) {
|
||||||
|
return codersdk.ValidationError{
|
||||||
|
Field: "slug",
|
||||||
|
Detail: fmt.Sprintf("%q does not match regex %q", slug, provisioner.AppSlugRegex),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, exists := appSlugs[slug]; exists {
|
||||||
|
return codersdk.ValidationError{
|
||||||
|
Field: "slug",
|
||||||
|
Detail: fmt.Sprintf("%q is already in use", slug),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appSlugs[slug] = struct{}{}
|
||||||
|
|
||||||
|
health := database.WorkspaceAppHealthDisabled
|
||||||
|
if app.Healthcheck == nil {
|
||||||
|
app.Healthcheck = &agentproto.CreateSubAgentRequest_App_Healthcheck{}
|
||||||
|
}
|
||||||
|
if app.Healthcheck.Url != "" {
|
||||||
|
health = database.WorkspaceAppHealthInitializing
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharingLevel database.AppSharingLevel
|
||||||
|
switch app.GetShare() {
|
||||||
|
case agentproto.CreateSubAgentRequest_App_OWNER:
|
||||||
|
sharingLevel = database.AppSharingLevelOwner
|
||||||
|
case agentproto.CreateSubAgentRequest_App_AUTHENTICATED:
|
||||||
|
sharingLevel = database.AppSharingLevelAuthenticated
|
||||||
|
case agentproto.CreateSubAgentRequest_App_PUBLIC:
|
||||||
|
sharingLevel = database.AppSharingLevelPublic
|
||||||
|
default:
|
||||||
|
return codersdk.ValidationError{
|
||||||
|
Field: "share",
|
||||||
|
Detail: fmt.Sprintf("%q is not a valid app sharing level", app.GetShare()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var openIn database.WorkspaceAppOpenIn
|
||||||
|
switch app.GetOpenIn() {
|
||||||
|
case agentproto.CreateSubAgentRequest_App_SLIM_WINDOW:
|
||||||
|
openIn = database.WorkspaceAppOpenInSlimWindow
|
||||||
|
case agentproto.CreateSubAgentRequest_App_TAB:
|
||||||
|
openIn = database.WorkspaceAppOpenInTab
|
||||||
|
default:
|
||||||
|
return codersdk.ValidationError{
|
||||||
|
Field: "open_in",
|
||||||
|
Detail: fmt.Sprintf("%q is not an open in setting", app.GetOpenIn()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := a.Database.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{
|
||||||
|
ID: uuid.New(),
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
AgentID: subAgent.ID,
|
||||||
|
Slug: app.Slug,
|
||||||
|
DisplayName: app.GetDisplayName(),
|
||||||
|
Icon: app.GetIcon(),
|
||||||
|
Command: sql.NullString{
|
||||||
|
Valid: app.GetCommand() != "",
|
||||||
|
String: app.GetCommand(),
|
||||||
|
},
|
||||||
|
Url: sql.NullString{
|
||||||
|
Valid: app.GetUrl() != "",
|
||||||
|
String: app.GetUrl(),
|
||||||
|
},
|
||||||
|
External: app.GetExternal(),
|
||||||
|
Subdomain: app.GetSubdomain(),
|
||||||
|
SharingLevel: sharingLevel,
|
||||||
|
HealthcheckUrl: app.Healthcheck.Url,
|
||||||
|
HealthcheckInterval: app.Healthcheck.Interval,
|
||||||
|
HealthcheckThreshold: app.Healthcheck.Threshold,
|
||||||
|
Health: health,
|
||||||
|
DisplayOrder: app.GetOrder(),
|
||||||
|
Hidden: app.GetHidden(),
|
||||||
|
OpenIn: openIn,
|
||||||
|
DisplayGroup: sql.NullString{
|
||||||
|
Valid: app.GetGroup() != "",
|
||||||
|
String: app.GetGroup(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return xerrors.Errorf("insert workspace app: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
appErr := &agentproto.CreateSubAgentResponse_AppCreationError{
|
||||||
|
Index: int32(i), //nolint:gosec // This would only overflow if we created 2 billion apps.
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationErr codersdk.ValidationError
|
||||||
|
if errors.As(err, &validationErr) {
|
||||||
|
appErr.Field = &validationErr.Field
|
||||||
|
appErr.Error = validationErr.Detail
|
||||||
|
}
|
||||||
|
|
||||||
|
appCreationErrors = append(appCreationErrors, appErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &agentproto.CreateSubAgentResponse{
|
return &agentproto.CreateSubAgentResponse{
|
||||||
Agent: &agentproto.SubAgent{
|
Agent: &agentproto.SubAgent{
|
||||||
Name: subAgent.Name,
|
Name: subAgent.Name,
|
||||||
Id: subAgent.ID[:],
|
Id: subAgent.ID[:],
|
||||||
AuthToken: subAgent.AuthToken[:],
|
AuthToken: subAgent.AuthToken[:],
|
||||||
},
|
},
|
||||||
|
AppCreationErrors: appCreationErrors,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,6 +21,8 @@ import (
|
|||||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||||
"github.com/coder/coder/v2/coderd/rbac"
|
"github.com/coder/coder/v2/coderd/rbac"
|
||||||
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||||
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/coder/v2/testutil"
|
"github.com/coder/coder/v2/testutil"
|
||||||
"github.com/coder/quartz"
|
"github.com/coder/quartz"
|
||||||
)
|
)
|
||||||
@ -92,12 +94,12 @@ func TestSubAgentAPI(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
agentName string
|
agentName string
|
||||||
agentDir string
|
agentDir string
|
||||||
agentArch string
|
agentArch string
|
||||||
agentOS string
|
agentOS string
|
||||||
shouldErr bool
|
expectedError *codersdk.ValidationError
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Ok",
|
name: "Ok",
|
||||||
@ -112,7 +114,10 @@ func TestSubAgentAPI(t *testing.T) {
|
|||||||
agentDir: "/workspaces/wibble",
|
agentDir: "/workspaces/wibble",
|
||||||
agentArch: "amd64",
|
agentArch: "amd64",
|
||||||
agentOS: "linux",
|
agentOS: "linux",
|
||||||
shouldErr: true,
|
expectedError: &codersdk.ValidationError{
|
||||||
|
Field: "name",
|
||||||
|
Detail: "agent name \"some_child_agent\" does not match regex \"(?i)^[a-z0-9](-?[a-z0-9])*$\"",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "EmptyName",
|
name: "EmptyName",
|
||||||
@ -120,7 +125,10 @@ func TestSubAgentAPI(t *testing.T) {
|
|||||||
agentDir: "/workspaces/wibble",
|
agentDir: "/workspaces/wibble",
|
||||||
agentArch: "amd64",
|
agentArch: "amd64",
|
||||||
agentOS: "linux",
|
agentOS: "linux",
|
||||||
shouldErr: true,
|
expectedError: &codersdk.ValidationError{
|
||||||
|
Field: "name",
|
||||||
|
Detail: "agent name cannot be empty",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,8 +150,11 @@ func TestSubAgentAPI(t *testing.T) {
|
|||||||
Architecture: tt.agentArch,
|
Architecture: tt.agentArch,
|
||||||
OperatingSystem: tt.agentOS,
|
OperatingSystem: tt.agentOS,
|
||||||
})
|
})
|
||||||
if tt.shouldErr {
|
if tt.expectedError != nil {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
var validationErr codersdk.ValidationError
|
||||||
|
require.ErrorAs(t, err, &validationErr)
|
||||||
|
require.Equal(t, *tt.expectedError, validationErr)
|
||||||
} else {
|
} else {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@ -164,6 +175,590 @@ func TestSubAgentAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type expectedAppError struct {
|
||||||
|
index int32
|
||||||
|
field string
|
||||||
|
error string
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("CreateSubAgentWithApps", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
apps []*proto.CreateSubAgentRequest_App
|
||||||
|
expectApps []database.WorkspaceApp
|
||||||
|
expectedAppErrors []expectedAppError
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "OK",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "code-server",
|
||||||
|
DisplayName: ptr.Ref("VS Code"),
|
||||||
|
Icon: ptr.Ref("/icon/code.svg"),
|
||||||
|
Url: ptr.Ref("http://localhost:13337"),
|
||||||
|
Share: proto.CreateSubAgentRequest_App_OWNER.Enum(),
|
||||||
|
Subdomain: ptr.Ref(false),
|
||||||
|
OpenIn: proto.CreateSubAgentRequest_App_SLIM_WINDOW.Enum(),
|
||||||
|
Healthcheck: &proto.CreateSubAgentRequest_App_Healthcheck{
|
||||||
|
Interval: 5,
|
||||||
|
Threshold: 6,
|
||||||
|
Url: "http://localhost:13337/healthz",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "vim",
|
||||||
|
Command: ptr.Ref("vim"),
|
||||||
|
DisplayName: ptr.Ref("Vim"),
|
||||||
|
Icon: ptr.Ref("/icon/vim.svg"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{
|
||||||
|
{
|
||||||
|
Slug: "code-server",
|
||||||
|
DisplayName: "VS Code",
|
||||||
|
Icon: "/icon/code.svg",
|
||||||
|
Command: sql.NullString{},
|
||||||
|
Url: sql.NullString{Valid: true, String: "http://localhost:13337"},
|
||||||
|
HealthcheckUrl: "http://localhost:13337/healthz",
|
||||||
|
HealthcheckInterval: 5,
|
||||||
|
HealthcheckThreshold: 6,
|
||||||
|
Health: database.WorkspaceAppHealthInitializing,
|
||||||
|
Subdomain: false,
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
External: false,
|
||||||
|
DisplayOrder: 0,
|
||||||
|
Hidden: false,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
DisplayGroup: sql.NullString{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "vim",
|
||||||
|
DisplayName: "Vim",
|
||||||
|
Icon: "/icon/vim.svg",
|
||||||
|
Command: sql.NullString{Valid: true, String: "vim"},
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "EmptyAppSlug",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "",
|
||||||
|
DisplayName: ptr.Ref("App"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{},
|
||||||
|
expectedAppErrors: []expectedAppError{
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
field: "slug",
|
||||||
|
error: "must not be empty",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidAppSlugWithUnderscores",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "invalid_slug_with_underscores",
|
||||||
|
DisplayName: ptr.Ref("App"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{},
|
||||||
|
expectedAppErrors: []expectedAppError{
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
field: "slug",
|
||||||
|
error: "\"invalid_slug_with_underscores\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidAppSlugWithUppercase",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "InvalidSlug",
|
||||||
|
DisplayName: ptr.Ref("App"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{},
|
||||||
|
expectedAppErrors: []expectedAppError{
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
field: "slug",
|
||||||
|
error: "\"InvalidSlug\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidAppSlugStartsWithHyphen",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "-invalid-app",
|
||||||
|
DisplayName: ptr.Ref("App"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{},
|
||||||
|
expectedAppErrors: []expectedAppError{
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
field: "slug",
|
||||||
|
error: "\"-invalid-app\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidAppSlugEndsWithHyphen",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "invalid-app-",
|
||||||
|
DisplayName: ptr.Ref("App"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{},
|
||||||
|
expectedAppErrors: []expectedAppError{
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
field: "slug",
|
||||||
|
error: "\"invalid-app-\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidAppSlugWithDoubleHyphens",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "invalid--app",
|
||||||
|
DisplayName: ptr.Ref("App"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{},
|
||||||
|
expectedAppErrors: []expectedAppError{
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
field: "slug",
|
||||||
|
error: "\"invalid--app\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidAppSlugWithSpaces",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "invalid app",
|
||||||
|
DisplayName: ptr.Ref("App"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{},
|
||||||
|
expectedAppErrors: []expectedAppError{
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
field: "slug",
|
||||||
|
error: "\"invalid app\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MultipleAppsWithErrorInSecond",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "valid-app",
|
||||||
|
DisplayName: ptr.Ref("Valid App"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "Invalid_App",
|
||||||
|
DisplayName: ptr.Ref("Invalid App"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{
|
||||||
|
{
|
||||||
|
Slug: "valid-app",
|
||||||
|
DisplayName: "Valid App",
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedAppErrors: []expectedAppError{
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
field: "slug",
|
||||||
|
error: "\"Invalid_App\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AppWithAllSharingLevels",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "owner-app",
|
||||||
|
Share: proto.CreateSubAgentRequest_App_OWNER.Enum(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "authenticated-app",
|
||||||
|
Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "public-app",
|
||||||
|
Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{
|
||||||
|
{
|
||||||
|
Slug: "authenticated-app",
|
||||||
|
SharingLevel: database.AppSharingLevelAuthenticated,
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "owner-app",
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "public-app",
|
||||||
|
SharingLevel: database.AppSharingLevelPublic,
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AppWithDifferentOpenInOptions",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "window-app",
|
||||||
|
OpenIn: proto.CreateSubAgentRequest_App_SLIM_WINDOW.Enum(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "tab-app",
|
||||||
|
OpenIn: proto.CreateSubAgentRequest_App_TAB.Enum(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{
|
||||||
|
{
|
||||||
|
Slug: "tab-app",
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInTab,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "window-app",
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AppWithAllOptionalFields",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "full-app",
|
||||||
|
Command: ptr.Ref("echo hello"),
|
||||||
|
DisplayName: ptr.Ref("Full Featured App"),
|
||||||
|
External: ptr.Ref(true),
|
||||||
|
Group: ptr.Ref("Development"),
|
||||||
|
Hidden: ptr.Ref(true),
|
||||||
|
Icon: ptr.Ref("/icon/app.svg"),
|
||||||
|
Order: ptr.Ref(int32(10)),
|
||||||
|
Subdomain: ptr.Ref(true),
|
||||||
|
Url: ptr.Ref("http://localhost:8080"),
|
||||||
|
Healthcheck: &proto.CreateSubAgentRequest_App_Healthcheck{
|
||||||
|
Interval: 30,
|
||||||
|
Threshold: 3,
|
||||||
|
Url: "http://localhost:8080/health",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{
|
||||||
|
{
|
||||||
|
Slug: "full-app",
|
||||||
|
Command: sql.NullString{Valid: true, String: "echo hello"},
|
||||||
|
DisplayName: "Full Featured App",
|
||||||
|
External: true,
|
||||||
|
DisplayGroup: sql.NullString{Valid: true, String: "Development"},
|
||||||
|
Hidden: true,
|
||||||
|
Icon: "/icon/app.svg",
|
||||||
|
DisplayOrder: 10,
|
||||||
|
Subdomain: true,
|
||||||
|
Url: sql.NullString{Valid: true, String: "http://localhost:8080"},
|
||||||
|
HealthcheckUrl: "http://localhost:8080/health",
|
||||||
|
HealthcheckInterval: 30,
|
||||||
|
HealthcheckThreshold: 3,
|
||||||
|
Health: database.WorkspaceAppHealthInitializing,
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AppWithoutHealthcheck",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "no-health-app",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{
|
||||||
|
{
|
||||||
|
Slug: "no-health-app",
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
HealthcheckUrl: "",
|
||||||
|
HealthcheckInterval: 0,
|
||||||
|
HealthcheckThreshold: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DuplicateAppSlugs",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "duplicate-app",
|
||||||
|
DisplayName: ptr.Ref("First App"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "duplicate-app",
|
||||||
|
DisplayName: ptr.Ref("Second App"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{
|
||||||
|
{
|
||||||
|
Slug: "duplicate-app",
|
||||||
|
DisplayName: "First App",
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedAppErrors: []expectedAppError{
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
field: "slug",
|
||||||
|
error: "\"duplicate-app\" is already in use",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MultipleDuplicateAppSlugs",
|
||||||
|
apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "valid-app",
|
||||||
|
DisplayName: ptr.Ref("Valid App"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "duplicate-app",
|
||||||
|
DisplayName: ptr.Ref("First Duplicate"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "duplicate-app",
|
||||||
|
DisplayName: ptr.Ref("Second Duplicate"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "duplicate-app",
|
||||||
|
DisplayName: ptr.Ref("Third Duplicate"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectApps: []database.WorkspaceApp{
|
||||||
|
{
|
||||||
|
Slug: "duplicate-app",
|
||||||
|
DisplayName: "First Duplicate",
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "valid-app",
|
||||||
|
DisplayName: "Valid App",
|
||||||
|
SharingLevel: database.AppSharingLevelOwner,
|
||||||
|
Health: database.WorkspaceAppHealthDisabled,
|
||||||
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedAppErrors: []expectedAppError{
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
field: "slug",
|
||||||
|
error: "\"duplicate-app\" is already in use",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
field: "slug",
|
||||||
|
error: "\"duplicate-app\" is already in use",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
log := testutil.Logger(t)
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
clock := quartz.NewMock(t)
|
||||||
|
|
||||||
|
db, org := newDatabaseWithOrg(t)
|
||||||
|
user, agent := newUserWithWorkspaceAgent(t, db, org)
|
||||||
|
api := newAgentAPI(t, log, db, clock, user, org, agent)
|
||||||
|
|
||||||
|
createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
||||||
|
Name: "child-agent",
|
||||||
|
Directory: "/workspaces/coder",
|
||||||
|
Architecture: "amd64",
|
||||||
|
OperatingSystem: "linux",
|
||||||
|
Apps: tt.apps,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
agentID, err := uuid.FromBytes(createResp.Agent.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test.
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Sort the apps for determinism
|
||||||
|
slices.SortFunc(apps, func(a, b database.WorkspaceApp) int {
|
||||||
|
return cmp.Compare(a.Slug, b.Slug)
|
||||||
|
})
|
||||||
|
slices.SortFunc(tt.expectApps, func(a, b database.WorkspaceApp) int {
|
||||||
|
return cmp.Compare(a.Slug, b.Slug)
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Len(t, apps, len(tt.expectApps))
|
||||||
|
|
||||||
|
for idx, app := range apps {
|
||||||
|
assert.Equal(t, tt.expectApps[idx].Slug, app.Slug)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].Command, app.Command)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].DisplayName, app.DisplayName)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].External, app.External)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].DisplayGroup, app.DisplayGroup)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].HealthcheckInterval, app.HealthcheckInterval)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].HealthcheckThreshold, app.HealthcheckThreshold)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].HealthcheckUrl, app.HealthcheckUrl)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].Hidden, app.Hidden)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].Icon, app.Icon)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].OpenIn, app.OpenIn)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].DisplayOrder, app.DisplayOrder)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].SharingLevel, app.SharingLevel)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].Subdomain, app.Subdomain)
|
||||||
|
assert.Equal(t, tt.expectApps[idx].Url, app.Url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify expected app creation errors
|
||||||
|
require.Len(t, createResp.AppCreationErrors, len(tt.expectedAppErrors), "Number of app creation errors should match expected")
|
||||||
|
|
||||||
|
// Build a map of actual errors by index for easier testing
|
||||||
|
actualErrorMap := make(map[int32]*proto.CreateSubAgentResponse_AppCreationError)
|
||||||
|
for _, appErr := range createResp.AppCreationErrors {
|
||||||
|
actualErrorMap[appErr.Index] = appErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each expected error
|
||||||
|
for _, expectedErr := range tt.expectedAppErrors {
|
||||||
|
actualErr, exists := actualErrorMap[expectedErr.index]
|
||||||
|
require.True(t, exists, "Expected app creation error at index %d", expectedErr.index)
|
||||||
|
|
||||||
|
require.NotNil(t, actualErr.Field, "Field should be set for validation error at index %d", expectedErr.index)
|
||||||
|
require.Equal(t, expectedErr.field, *actualErr.Field, "Field name should match for error at index %d", expectedErr.index)
|
||||||
|
require.Contains(t, actualErr.Error, expectedErr.error, "Error message should contain expected text for error at index %d", expectedErr.index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("ValidationErrorFieldMapping", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
log := testutil.Logger(t)
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
clock := quartz.NewMock(t)
|
||||||
|
|
||||||
|
db, org := newDatabaseWithOrg(t)
|
||||||
|
user, agent := newUserWithWorkspaceAgent(t, db, org)
|
||||||
|
api := newAgentAPI(t, log, db, clock, user, org, agent)
|
||||||
|
|
||||||
|
// Test different types of validation errors to ensure field mapping works correctly
|
||||||
|
createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
||||||
|
Name: "validation-test-agent",
|
||||||
|
Directory: "/workspace",
|
||||||
|
Architecture: "amd64",
|
||||||
|
OperatingSystem: "linux",
|
||||||
|
Apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "", // Empty slug - should error on apps[0].slug
|
||||||
|
DisplayName: ptr.Ref("Empty Slug App"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "Invalid_Slug_With_Underscores", // Invalid characters - should error on apps[1].slug
|
||||||
|
DisplayName: ptr.Ref("Invalid Characters App"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "duplicate-slug", // First occurrence - should succeed
|
||||||
|
DisplayName: ptr.Ref("First Duplicate"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "duplicate-slug", // Duplicate - should error on apps[3].slug
|
||||||
|
DisplayName: ptr.Ref("Second Duplicate"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "-invalid-start", // Invalid start character - should error on apps[4].slug
|
||||||
|
DisplayName: ptr.Ref("Invalid Start App"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Agent should be created successfully
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, createResp.Agent)
|
||||||
|
|
||||||
|
// Should have 4 app creation errors (indices 0, 1, 3, 4)
|
||||||
|
require.Len(t, createResp.AppCreationErrors, 4)
|
||||||
|
|
||||||
|
errorMap := make(map[int32]*proto.CreateSubAgentResponse_AppCreationError)
|
||||||
|
for _, appErr := range createResp.AppCreationErrors {
|
||||||
|
errorMap[appErr.Index] = appErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each specific validation error and its field
|
||||||
|
require.Contains(t, errorMap, int32(0))
|
||||||
|
require.NotNil(t, errorMap[0].Field)
|
||||||
|
require.Equal(t, "slug", *errorMap[0].Field)
|
||||||
|
require.Contains(t, errorMap[0].Error, "must not be empty")
|
||||||
|
|
||||||
|
require.Contains(t, errorMap, int32(1))
|
||||||
|
require.NotNil(t, errorMap[1].Field)
|
||||||
|
require.Equal(t, "slug", *errorMap[1].Field)
|
||||||
|
require.Contains(t, errorMap[1].Error, "Invalid_Slug_With_Underscores")
|
||||||
|
|
||||||
|
require.Contains(t, errorMap, int32(3))
|
||||||
|
require.NotNil(t, errorMap[3].Field)
|
||||||
|
require.Equal(t, "slug", *errorMap[3].Field)
|
||||||
|
require.Contains(t, errorMap[3].Error, "duplicate-slug")
|
||||||
|
|
||||||
|
require.Contains(t, errorMap, int32(4))
|
||||||
|
require.NotNil(t, errorMap[4].Field)
|
||||||
|
require.Equal(t, "slug", *errorMap[4].Field)
|
||||||
|
require.Contains(t, errorMap[4].Error, "-invalid-start")
|
||||||
|
|
||||||
|
// Verify only the valid app (index 2) was created
|
||||||
|
agentID, err := uuid.FromBytes(createResp.Agent.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
apps, err := db.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test.
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, apps, 1)
|
||||||
|
require.Equal(t, "duplicate-slug", apps[0].Slug)
|
||||||
|
require.Equal(t, "First Duplicate", apps[0].DisplayName)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("DeleteSubAgent", func(t *testing.T) {
|
t.Run("DeleteSubAgent", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@ -279,6 +874,69 @@ func TestSubAgentAPI(t *testing.T) {
|
|||||||
_, err = db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentOne.ID) //nolint:gocritic // this is a test.
|
_, err = db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentOne.ID) //nolint:gocritic // this is a test.
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("DeletesWorkspaceApps", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Skip test on in-memory database since CASCADE DELETE is not implemented
|
||||||
|
if !dbtestutil.WillUsePostgres() {
|
||||||
|
t.Skip("CASCADE DELETE behavior requires PostgreSQL")
|
||||||
|
}
|
||||||
|
|
||||||
|
log := testutil.Logger(t)
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
clock := quartz.NewMock(t)
|
||||||
|
|
||||||
|
db, org := newDatabaseWithOrg(t)
|
||||||
|
user, agent := newUserWithWorkspaceAgent(t, db, org)
|
||||||
|
api := newAgentAPI(t, log, db, clock, user, org, agent)
|
||||||
|
|
||||||
|
// Given: A sub agent with workspace apps
|
||||||
|
createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
||||||
|
Name: "child-agent-with-apps",
|
||||||
|
Directory: "/workspaces/coder",
|
||||||
|
Architecture: "amd64",
|
||||||
|
OperatingSystem: "linux",
|
||||||
|
Apps: []*proto.CreateSubAgentRequest_App{
|
||||||
|
{
|
||||||
|
Slug: "code-server",
|
||||||
|
DisplayName: ptr.Ref("VS Code"),
|
||||||
|
Icon: ptr.Ref("/icon/code.svg"),
|
||||||
|
Url: ptr.Ref("http://localhost:13337"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "vim",
|
||||||
|
Command: ptr.Ref("vim"),
|
||||||
|
DisplayName: ptr.Ref("Vim"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
subAgentID, err := uuid.FromBytes(createResp.Agent.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify that the apps were created
|
||||||
|
apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), subAgentID) //nolint:gocritic // this is a test.
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, apps, 2)
|
||||||
|
|
||||||
|
// When: We delete the sub agent
|
||||||
|
_, err = api.DeleteSubAgent(ctx, &proto.DeleteSubAgentRequest{
|
||||||
|
Id: createResp.Agent.Id,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Then: The agent is deleted
|
||||||
|
_, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), subAgentID) //nolint:gocritic // this is a test.
|
||||||
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||||
|
|
||||||
|
// And: The apps are also deleted (due to CASCADE DELETE)
|
||||||
|
// Use raw database since authorization layer requires agent to exist
|
||||||
|
appsAfterDeletion, err := db.GetWorkspaceAppsByAgentID(ctx, subAgentID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, appsAfterDeletion)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ListSubAgents", func(t *testing.T) {
|
t.Run("ListSubAgents", func(t *testing.T) {
|
||||||
|
@ -333,7 +333,7 @@ var (
|
|||||||
orgID.String(): {},
|
orgID.String(): {},
|
||||||
},
|
},
|
||||||
User: rbac.Permissions(map[string][]policy.Action{
|
User: rbac.Permissions(map[string][]policy.Action{
|
||||||
rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionCreateAgent, policy.ActionDeleteAgent},
|
rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionCreateAgent, policy.ActionDeleteAgent},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
Reference in New Issue
Block a user