Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ed709b306 | |||
| da2490b9cb | |||
| b0c09eab03 | |||
| 014693ba34 | |||
| 62ba27b08f | |||
| 99d8b7f8d0 | |||
| b1e18f2398 | |||
| 6759b51cd6 | |||
| 1e2d2b92af | |||
| 91be688e39 | |||
| 2add69a33e | |||
| d11f9bf094 | |||
| 7fd13019e5 | |||
| a16debee76 | |||
| a502640431 | |||
| f7f025343f | |||
| b955e102ff | |||
| efe4cb1f66 | |||
| f72f09c110 |
+25
-24
@@ -39,6 +39,7 @@ import (
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/clistat"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentfiles"
|
||||
"github.com/coder/coder/v2/agent/agentscripts"
|
||||
@@ -553,7 +554,7 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient28
|
||||
// Set up collect and report as a single ticker with two channels,
|
||||
// this is to allow collection and reporting to be triggered
|
||||
// independently of each other.
|
||||
go func() {
|
||||
agentutil.Go(ctx, a.logger, func() {
|
||||
t := time.NewTicker(a.reportMetadataInterval)
|
||||
defer func() {
|
||||
t.Stop()
|
||||
@@ -578,9 +579,9 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient28
|
||||
wake(collect)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
go func() {
|
||||
agentutil.Go(ctx, a.logger, func() {
|
||||
defer close(collectDone)
|
||||
|
||||
var (
|
||||
@@ -627,7 +628,7 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient28
|
||||
// We send the result to the channel in the goroutine to avoid
|
||||
// sending the same result multiple times. So, we don't care about
|
||||
// the return values.
|
||||
go flight.Do(md.Key, func() {
|
||||
agentutil.Go(ctx, a.logger, func() { flight.Do(md.Key, func() {
|
||||
ctx := slog.With(ctx, slog.F("key", md.Key))
|
||||
lastCollectedAtMu.RLock()
|
||||
collectedAt, ok := lastCollectedAts[md.Key]
|
||||
@@ -680,10 +681,10 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient28
|
||||
lastCollectedAts[md.Key] = now
|
||||
lastCollectedAtMu.Unlock()
|
||||
}
|
||||
})
|
||||
}) })
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
// Gather metadata updates and report them once every interval. If a
|
||||
// previous report is in flight, wait for it to complete before
|
||||
@@ -734,14 +735,14 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient28
|
||||
}
|
||||
|
||||
reportInFlight = true
|
||||
go func() {
|
||||
agentutil.Go(ctx, a.logger, func() {
|
||||
a.logger.Debug(ctx, "batch updating metadata")
|
||||
ctx, cancel := context.WithTimeout(ctx, reportTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := aAPI.BatchUpdateMetadata(ctx, &proto.BatchUpdateMetadataRequest{Metadata: metadata})
|
||||
reportError <- err
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1518,10 +1519,10 @@ func (a *agent) trackGoroutine(fn func()) error {
|
||||
return xerrors.Errorf("track conn goroutine: %w", ErrAgentClosing)
|
||||
}
|
||||
a.closeWaitGroup.Add(1)
|
||||
go func() {
|
||||
agentutil.Go(a.hardCtx, a.logger, func() {
|
||||
defer a.closeWaitGroup.Done()
|
||||
fn()
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1625,15 +1626,15 @@ func (a *agent) createTailnet(
|
||||
clog.Info(ctx, "accepted conn")
|
||||
wg.Add(1)
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
agentutil.Go(ctx, clog, func() {
|
||||
select {
|
||||
case <-closed:
|
||||
case <-a.hardCtx.Done():
|
||||
_ = conn.Close()
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
go func() {
|
||||
})
|
||||
agentutil.Go(ctx, clog, func() {
|
||||
defer close(closed)
|
||||
sErr := speedtest.ServeConn(conn)
|
||||
if sErr != nil {
|
||||
@@ -1641,7 +1642,7 @@ func (a *agent) createTailnet(
|
||||
return
|
||||
}
|
||||
clog.Info(ctx, "test ended")
|
||||
}()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}); err != nil {
|
||||
@@ -1668,13 +1669,13 @@ func (a *agent) createTailnet(
|
||||
WriteTimeout: 20 * time.Second,
|
||||
ErrorLog: slog.Stdlib(ctx, a.logger.Named("http_api_server"), slog.LevelInfo),
|
||||
}
|
||||
go func() {
|
||||
agentutil.Go(ctx, a.logger, func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-a.hardCtx.Done():
|
||||
}
|
||||
_ = server.Close()
|
||||
}()
|
||||
})
|
||||
|
||||
apiServErr := server.Serve(apiListener)
|
||||
if apiServErr != nil && !xerrors.Is(apiServErr, http.ErrServerClosed) && !strings.Contains(apiServErr.Error(), "use of closed network connection") {
|
||||
@@ -1716,7 +1717,7 @@ func (a *agent) runCoordinator(ctx context.Context, tClient tailnetproto.DRPCTai
|
||||
coordination := ctrl.New(coordinate)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
agentutil.Go(ctx, a.logger, func() {
|
||||
defer close(errCh)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -1728,7 +1729,7 @@ func (a *agent) runCoordinator(ctx context.Context, tClient tailnetproto.DRPCTai
|
||||
case err := <-coordination.Wait():
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
})
|
||||
return <-errCh
|
||||
}
|
||||
|
||||
@@ -1819,7 +1820,7 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
agentutil.Go(pingCtx, a.logger, func() {
|
||||
defer wg.Done()
|
||||
duration, p2p, _, err := a.network.Ping(pingCtx, addresses[0].Addr())
|
||||
if err != nil {
|
||||
@@ -1833,7 +1834,7 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
|
||||
} else {
|
||||
derpConns++
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
sort.Float64s(durations)
|
||||
@@ -2031,13 +2032,13 @@ func (a *agent) Close() error {
|
||||
|
||||
// Wait for the graceful shutdown to complete, but don't wait forever so
|
||||
// that we don't break user expectations.
|
||||
go func() {
|
||||
agentutil.Go(a.hardCtx, a.logger, func() {
|
||||
defer a.hardCancel()
|
||||
select {
|
||||
case <-a.hardCtx.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
// Wait for lifecycle to be reported
|
||||
lifecycleWaitLoop:
|
||||
@@ -2127,13 +2128,13 @@ const EnvAgentSubsystem = "CODER_AGENT_SUBSYSTEM"
|
||||
// eitherContext returns a context that is canceled when either context ends.
|
||||
func eitherContext(a, b context.Context) context.Context {
|
||||
ctx, cancel := context.WithCancel(a)
|
||||
go func() {
|
||||
agentutil.Go(ctx, slog.Logger{}, func() {
|
||||
defer cancel()
|
||||
select {
|
||||
case <-a.Done():
|
||||
case <-b.Done():
|
||||
}
|
||||
}()
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/ignore"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/usershell"
|
||||
@@ -563,11 +564,11 @@ func (api *API) discoverDevcontainersInProject(projectPath string) error {
|
||||
|
||||
if dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting {
|
||||
api.asyncWg.Add(1)
|
||||
go func() {
|
||||
agentutil.Go(api.ctx, api.logger, func() {
|
||||
defer api.asyncWg.Done()
|
||||
|
||||
_ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
api.mu.Unlock()
|
||||
@@ -1405,14 +1406,6 @@ 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()
|
||||
|
||||
@@ -1431,9 +1424,9 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques
|
||||
api.knownDevcontainers[dc.WorkspaceFolder] = dc
|
||||
api.broadcastUpdatesLocked()
|
||||
|
||||
go func() {
|
||||
agentutil.Go(ctx, api.logger, func() {
|
||||
_ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath, WithRemoveExistingContainer())
|
||||
}()
|
||||
})
|
||||
|
||||
api.mu.Unlock()
|
||||
|
||||
@@ -1635,25 +1628,16 @@ func (api *API) cleanupSubAgents(ctx context.Context) error {
|
||||
api.mu.Lock()
|
||||
defer api.mu.Unlock()
|
||||
|
||||
// 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))
|
||||
injected := make(map[uuid.UUID]bool, len(api.injectedSubAgentProcs))
|
||||
for _, proc := range api.injectedSubAgentProcs {
|
||||
keep[proc.agent.ID] = true
|
||||
}
|
||||
for _, dc := range api.knownDevcontainers {
|
||||
if dc.SubagentID.Valid {
|
||||
keep[dc.SubagentID.UUID] = true
|
||||
}
|
||||
injected[proc.agent.ID] = true
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
var errs []error
|
||||
for _, agent := range agents {
|
||||
if keep[agent.ID] {
|
||||
if injected[agent.ID] {
|
||||
continue
|
||||
}
|
||||
client := *api.subAgentClient.Load()
|
||||
@@ -1664,11 +1648,10 @@ 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 errors.Join(errs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeInjectSubAgentIntoContainerLocked injects a subagent into a dev
|
||||
@@ -2019,11 +2002,7 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
|
||||
// logger.Warn(ctx, "set CAP_NET_ADMIN on agent binary failed", slog.Error(err))
|
||||
// }
|
||||
|
||||
// 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)
|
||||
deleteSubAgent := 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,11 +437,7 @@ func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.S
|
||||
}
|
||||
}
|
||||
|
||||
// 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.ID = uuid.New()
|
||||
agent.AuthToken = uuid.New()
|
||||
if m.agents == nil {
|
||||
m.agents = make(map[uuid.UUID]agentcontainers.SubAgent)
|
||||
@@ -1039,30 +1035,6 @@ 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,12 +24,10 @@ type SubAgent struct {
|
||||
DisplayApps []codersdk.DisplayApp
|
||||
}
|
||||
|
||||
// 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.
|
||||
// CloneConfig makes a copy of SubAgent without ID and AuthToken. The
|
||||
// name is inherited from the devcontainer.
|
||||
func (s SubAgent) CloneConfig(dc codersdk.WorkspaceAgentDevcontainer) SubAgent {
|
||||
return SubAgent{
|
||||
ID: dc.SubagentID.UUID,
|
||||
Name: dc.Name,
|
||||
Directory: s.Directory,
|
||||
Architecture: s.Architecture,
|
||||
@@ -192,11 +190,6 @@ 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
|
||||
@@ -235,7 +228,6 @@ 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,102 +306,3 @@ 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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -473,10 +474,10 @@ func (r *Runner) trackCommandGoroutine(fn func()) error {
|
||||
return xerrors.New("track command goroutine: closed")
|
||||
}
|
||||
r.cmdCloseWait.Add(1)
|
||||
go func() {
|
||||
agentutil.Go(r.cronCtx, r.Logger, func() {
|
||||
defer r.cmdCloseWait.Done()
|
||||
fn()
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentsocket/proto"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/codersdk/drpcsdk"
|
||||
)
|
||||
@@ -79,10 +80,10 @@ func NewServer(logger slog.Logger, opts ...Option) (*Server, error) {
|
||||
server.logger.Info(server.ctx, "agent socket server started", slog.F("path", server.path))
|
||||
|
||||
server.wg.Add(1)
|
||||
go func() {
|
||||
agentutil.Go(server.ctx, server.logger, func() {
|
||||
defer server.wg.Done()
|
||||
server.acceptConnections()
|
||||
}()
|
||||
})
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
+11
-10
@@ -29,6 +29,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentrsa"
|
||||
"github.com/coder/coder/v2/agent/usershell"
|
||||
@@ -634,13 +635,13 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "stdin_pipe").Add(1)
|
||||
return xerrors.Errorf("create stdin pipe: %w", err)
|
||||
}
|
||||
go func() {
|
||||
agentutil.Go(session.Context(), logger, func() {
|
||||
_, err := io.Copy(stdinPipe, session)
|
||||
if err != nil {
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "stdin_io_copy").Add(1)
|
||||
}
|
||||
_ = stdinPipe.Close()
|
||||
}()
|
||||
})
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "start_command").Add(1)
|
||||
@@ -662,11 +663,11 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
|
||||
session.Signals(nil)
|
||||
close(sigs)
|
||||
}()
|
||||
go func() {
|
||||
agentutil.Go(session.Context(), logger, func() {
|
||||
for sig := range sigs {
|
||||
handleSignal(logger, sig, cmd.Process, s.metrics, magicTypeLabel)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
@@ -737,7 +738,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
|
||||
session.Signals(nil)
|
||||
close(sigs)
|
||||
}()
|
||||
go func() {
|
||||
agentutil.Go(ctx, logger, func() {
|
||||
for {
|
||||
if sigs == nil && windowSize == nil {
|
||||
return
|
||||
@@ -764,14 +765,14 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
go func() {
|
||||
agentutil.Go(ctx, logger, func() {
|
||||
_, err := io.Copy(ptty.InputWriter(), session)
|
||||
if err != nil {
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "input_io_copy").Add(1)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
// We need to wait for the command output to finish copying. It's safe to
|
||||
// just do this copy on the main handler goroutine because one of two things
|
||||
@@ -1213,11 +1214,11 @@ func (s *Server) Close() error {
|
||||
// but Close() may not have completed.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
ch := make(chan error, 1)
|
||||
go func() {
|
||||
agentutil.Go(ctx, s.logger, func() {
|
||||
// TODO(mafredri): Implement shutdown, SIGHUP running commands, etc.
|
||||
// For now we just close the server.
|
||||
ch <- s.Close()
|
||||
}()
|
||||
})
|
||||
var err error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
)
|
||||
|
||||
// Bicopy copies all of the data between the two connections and will close them
|
||||
@@ -35,10 +38,10 @@ func Bicopy(ctx context.Context, c1, c2 io.ReadWriteCloser) {
|
||||
|
||||
// Convert waitgroup to a channel so we can also wait on the context.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
agentutil.Go(ctx, slog.Logger{}, func() {
|
||||
defer close(done)
|
||||
wg.Wait()
|
||||
}()
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
)
|
||||
|
||||
// streamLocalForwardPayload describes the extra data sent in a
|
||||
@@ -130,11 +131,11 @@ func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server,
|
||||
log.Debug(ctx, "SSH unix forward added to cache")
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
agentutil.Go(ctx, log, func() {
|
||||
<-ctx.Done()
|
||||
_ = ln.Close()
|
||||
}()
|
||||
go func() {
|
||||
})
|
||||
agentutil.Go(ctx, log, func() {
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
@@ -152,7 +153,7 @@ func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server,
|
||||
SocketPath: addr,
|
||||
})
|
||||
|
||||
go func() {
|
||||
agentutil.Go(ctx, log, func() {
|
||||
ch, reqs, err := conn.OpenChannel("forwarded-streamlocal@openssh.com", payload)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "open SSH unix forward channel to client", slog.Error(err))
|
||||
@@ -161,7 +162,7 @@ func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server,
|
||||
}
|
||||
go gossh.DiscardRequests(reqs)
|
||||
Bicopy(ctx, ch, c)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
h.Lock()
|
||||
@@ -171,7 +172,7 @@ func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server,
|
||||
h.Unlock()
|
||||
log.Debug(ctx, "SSH unix forward listener removed from cache")
|
||||
_ = ln.Close()
|
||||
}()
|
||||
})
|
||||
|
||||
return true, nil
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -122,10 +123,10 @@ func (x *x11Forwarder) x11Handler(sshCtx ssh.Context, sshSession ssh.Session) (d
|
||||
}
|
||||
|
||||
// clean up the X11 session if the SSH session completes.
|
||||
go func() {
|
||||
agentutil.Go(ctx, x.logger, func() {
|
||||
<-ctx.Done()
|
||||
x.closeAndRemoveSession(x11session)
|
||||
}()
|
||||
})
|
||||
|
||||
go x.listenForConnections(ctx, x11session, serverConn, x11)
|
||||
x.logger.Debug(ctx, "X11 forwarding started", slog.F("display", x11session.display))
|
||||
@@ -206,10 +207,10 @@ func (x *x11Forwarder) listenForConnections(
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
agentutil.Go(ctx, x.logger, func() {
|
||||
defer x.trackConn(conn, false)
|
||||
Bicopy(ctx, conn, channel)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package agentutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime/debug"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
)
|
||||
|
||||
// Go runs the provided function in a goroutine, recovering from panics and
|
||||
// logging them before re-panicking.
|
||||
func Go(ctx context.Context, log slog.Logger, fn func()) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Critical(ctx, "panic in goroutine",
|
||||
slog.F("panic", r),
|
||||
slog.F("stack", string(debug.Stack())),
|
||||
)
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
+3
-2
@@ -10,6 +10,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/quartz"
|
||||
@@ -69,7 +70,7 @@ func NewAppHealthReporterWithClock(
|
||||
continue
|
||||
}
|
||||
app := nextApp
|
||||
go func() {
|
||||
agentutil.Go(ctx, logger, func() {
|
||||
_ = clk.TickerFunc(ctx, time.Duration(app.Healthcheck.Interval)*time.Second, func() error {
|
||||
// We time out at the healthcheck interval to prevent getting too backed up, but
|
||||
// set it 1ms early so that it's not simultaneous with the next tick in testing,
|
||||
@@ -133,7 +134,7 @@ func NewAppHealthReporterWithClock(
|
||||
}
|
||||
return nil
|
||||
}, "healthcheck", app.Slug)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/agent/boundarylogproxy/codec"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
)
|
||||
@@ -133,11 +134,11 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
defer cancel()
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
agentutil.Go(ctx, s.logger, func() {
|
||||
defer s.wg.Done()
|
||||
<-ctx.Done()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
})
|
||||
|
||||
// This is intended to be a sane starting point for the read buffer size. It may be
|
||||
// grown by codec.ReadFrame if necessary.
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
)
|
||||
|
||||
@@ -76,7 +77,7 @@ func newBuffered(ctx context.Context, logger slog.Logger, execer agentexec.Exece
|
||||
// We do not need to separately monitor for the process exiting. When it
|
||||
// exits, our ptty.OutputReader() will return EOF after reading all process
|
||||
// output.
|
||||
go func() {
|
||||
agentutil.Go(ctx, logger, func() {
|
||||
buffer := make([]byte, 1024)
|
||||
for {
|
||||
read, err := ptty.OutputReader().Read(buffer)
|
||||
@@ -118,7 +119,7 @@ func newBuffered(ctx context.Context, logger slog.Logger, execer agentexec.Exece
|
||||
}
|
||||
rpty.state.cond.L.Unlock()
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
return rpty
|
||||
}
|
||||
@@ -133,7 +134,7 @@ func (rpty *bufferedReconnectingPTY) lifecycle(ctx context.Context, logger slog.
|
||||
logger.Debug(ctx, "reconnecting pty ready")
|
||||
rpty.state.setState(StateReady, nil)
|
||||
|
||||
state, reasonErr := rpty.state.waitForStateOrContext(ctx, StateClosing)
|
||||
state, reasonErr := rpty.state.waitForStateOrContext(ctx, StateClosing, logger)
|
||||
if state < StateClosing {
|
||||
// If we have not closed yet then the context is what unblocked us (which
|
||||
// means the agent is shutting down) so move into the closing phase.
|
||||
@@ -190,7 +191,7 @@ func (rpty *bufferedReconnectingPTY) Attach(ctx context.Context, connID string,
|
||||
delete(rpty.activeConns, connID)
|
||||
}()
|
||||
|
||||
state, err := rpty.state.waitForStateOrContext(ctx, StateReady)
|
||||
state, err := rpty.state.waitForStateOrContext(ctx, StateReady, logger)
|
||||
if state != StateReady {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
)
|
||||
@@ -177,20 +178,20 @@ func (s *ptyState) waitForState(state State) (State, error) {
|
||||
|
||||
// waitForStateOrContext blocks until the state or a greater one is reached or
|
||||
// the provided context ends.
|
||||
func (s *ptyState) waitForStateOrContext(ctx context.Context, state State) (State, error) {
|
||||
func (s *ptyState) waitForStateOrContext(ctx context.Context, state State, logger slog.Logger) (State, error) {
|
||||
s.cond.L.Lock()
|
||||
defer s.cond.L.Unlock()
|
||||
|
||||
nevermind := make(chan struct{})
|
||||
defer close(nevermind)
|
||||
go func() {
|
||||
agentutil.Go(ctx, logger, func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Wake up when the context ends.
|
||||
s.cond.Broadcast()
|
||||
case <-nevermind:
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
for ctx.Err() == nil && state > s.state {
|
||||
s.cond.Wait()
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
)
|
||||
|
||||
@@ -141,7 +142,7 @@ func (rpty *screenReconnectingPTY) lifecycle(ctx context.Context, logger slog.Lo
|
||||
logger.Debug(ctx, "reconnecting pty ready")
|
||||
rpty.state.setState(StateReady, nil)
|
||||
|
||||
state, reasonErr := rpty.state.waitForStateOrContext(ctx, StateClosing)
|
||||
state, reasonErr := rpty.state.waitForStateOrContext(ctx, StateClosing, logger)
|
||||
if state < StateClosing {
|
||||
// If we have not closed yet then the context is what unblocked us (which
|
||||
// means the agent is shutting down) so move into the closing phase.
|
||||
@@ -166,7 +167,7 @@ func (rpty *screenReconnectingPTY) Attach(ctx context.Context, _ string, conn ne
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
state, err := rpty.state.waitForStateOrContext(ctx, StateReady)
|
||||
state, err := rpty.state.waitForStateOrContext(ctx, StateReady, logger)
|
||||
if state != StateReady {
|
||||
return err
|
||||
}
|
||||
@@ -256,7 +257,7 @@ func (rpty *screenReconnectingPTY) doAttach(ctx context.Context, conn net.Conn,
|
||||
// We do not need to separately monitor for the process exiting. When it
|
||||
// exits, our ptty.OutputReader() will return EOF after reading all process
|
||||
// output.
|
||||
go func() {
|
||||
agentutil.Go(ctx, logger, func() {
|
||||
defer versionCancel()
|
||||
defer func() {
|
||||
err := conn.Close()
|
||||
@@ -298,7 +299,7 @@ func (rpty *screenReconnectingPTY) doAttach(ctx context.Context, conn net.Conn,
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
// Version seems to be the only command without a side effect (other than
|
||||
// making the version pop up briefly) so use it to wait for the session to
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/usershell"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
@@ -90,7 +91,7 @@ func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr err
|
||||
wg.Add(1)
|
||||
disconnected := s.reportConnection(uuid.New(), remoteAddrString)
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
agentutil.Go(ctx, clog, func() {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case <-closed:
|
||||
@@ -98,9 +99,9 @@ func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr err
|
||||
disconnected(1, "server shut down")
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
})
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
agentutil.Go(ctx, clog, func() {
|
||||
defer close(closed)
|
||||
defer wg.Done()
|
||||
err := s.handleConn(ctx, clog, conn)
|
||||
@@ -113,7 +114,7 @@ func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr err
|
||||
} else {
|
||||
disconnected(0, "")
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
return retErr
|
||||
@@ -226,18 +227,18 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co
|
||||
)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
agentutil.Go(ctx, connLogger, func() {
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
rpty.Close(ctx.Err())
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
go func() {
|
||||
agentutil.Go(ctx, connLogger, func() {
|
||||
rpty.Wait()
|
||||
s.reconnectingPTYs.Delete(msg.ID)
|
||||
}()
|
||||
})
|
||||
|
||||
connected = true
|
||||
sendConnected <- rpty
|
||||
|
||||
+3
-2
@@ -10,6 +10,7 @@ import (
|
||||
"tailscale.com/types/netlogtype"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentutil"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
)
|
||||
|
||||
@@ -86,13 +87,13 @@ func (s *statsReporter) reportLoop(ctx context.Context, dest statsDest) error {
|
||||
// use a separate goroutine to monitor the context so that we notice immediately, rather than
|
||||
// waiting for the next callback (which might never come if we are closing!)
|
||||
ctxDone := false
|
||||
go func() {
|
||||
agentutil.Go(ctx, s.logger, func() {
|
||||
<-ctx.Done()
|
||||
s.L.Lock()
|
||||
defer s.L.Unlock()
|
||||
ctxDone = true
|
||||
s.Broadcast()
|
||||
}()
|
||||
})
|
||||
defer s.logger.Debug(ctx, "reportLoop exiting")
|
||||
|
||||
s.L.Lock()
|
||||
|
||||
@@ -69,7 +69,7 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
|
||||
}
|
||||
default:
|
||||
text := "Enter a value"
|
||||
if !templateVersionParameter.Required {
|
||||
if defaultValue != "" {
|
||||
text += fmt.Sprintf(" (default: %q)", defaultValue)
|
||||
}
|
||||
text += ":"
|
||||
@@ -77,6 +77,10 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
|
||||
value, err = Prompt(inv, PromptOptions{
|
||||
Text: Bold(text),
|
||||
Validate: func(value string) error {
|
||||
// If empty, the default value will be used (if available).
|
||||
if value == "" && defaultValue != "" {
|
||||
value = defaultValue
|
||||
}
|
||||
return validateRichPrompt(value, templateVersionParameter)
|
||||
},
|
||||
})
|
||||
|
||||
+50
-3
@@ -323,6 +323,7 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
Action: WorkspaceCreate,
|
||||
TemplateVersionID: templateVersionID,
|
||||
NewWorkspaceName: workspaceName,
|
||||
Owner: workspaceOwner,
|
||||
|
||||
PresetParameters: presetParameters,
|
||||
RichParameterFile: parameterFlags.richParameterFile,
|
||||
@@ -456,6 +457,8 @@ type prepWorkspaceBuildArgs struct {
|
||||
Action WorkspaceCLIAction
|
||||
TemplateVersionID uuid.UUID
|
||||
NewWorkspaceName string
|
||||
// The owner is required when evaluating dynamic parameters
|
||||
Owner string
|
||||
|
||||
LastBuildParameters []codersdk.WorkspaceBuildParameter
|
||||
SourceWorkspaceParameters []codersdk.WorkspaceBuildParameter
|
||||
@@ -550,9 +553,14 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
|
||||
return nil, xerrors.Errorf("get template version: %w", err)
|
||||
}
|
||||
|
||||
templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get template version rich parameters: %w", err)
|
||||
dynamicParameters := true
|
||||
if templateVersion.TemplateID != nil {
|
||||
// TODO: This fetch is often redundant, as the caller often has the template already.
|
||||
template, err := client.Template(ctx, *templateVersion.TemplateID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get template: %w", err)
|
||||
}
|
||||
dynamicParameters = !template.UseClassicParameterFlow
|
||||
}
|
||||
|
||||
parameterFile := map[string]string{}
|
||||
@@ -574,6 +582,45 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
|
||||
WithRichParametersFile(parameterFile).
|
||||
WithRichParametersDefaults(args.RichParameterDefaults).
|
||||
WithUseParameterDefaults(args.UseParameterDefaults)
|
||||
|
||||
var templateVersionParameters []codersdk.TemplateVersionParameter
|
||||
if !dynamicParameters {
|
||||
templateVersionParameters, err = client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get template version rich parameters: %w", err)
|
||||
}
|
||||
} else {
|
||||
var ownerID uuid.UUID
|
||||
{ // Putting in its own block to limit scope of owningMember, as it might be nil
|
||||
owningMember, err := client.OrganizationMember(ctx, templateVersion.OrganizationID.String(), args.Owner)
|
||||
if err != nil {
|
||||
// This is unfortunate, but if we are an org owner, then we can create workspaces
|
||||
// for users that are not part of the organization.
|
||||
owningUser, uerr := client.User(ctx, args.Owner)
|
||||
if uerr != nil {
|
||||
return nil, xerrors.Errorf("get owning member: %w", err)
|
||||
}
|
||||
ownerID = owningUser.ID
|
||||
} else {
|
||||
ownerID = owningMember.UserID
|
||||
}
|
||||
}
|
||||
|
||||
initial := make(map[string]string)
|
||||
for _, v := range resolver.InitialValues() {
|
||||
initial[v.Name] = v.Value
|
||||
}
|
||||
|
||||
eval, err := client.EvaluateTemplateVersion(ctx, templateVersion.ID, ownerID, initial)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("evaluate template version dynamic parameters: %w", err)
|
||||
}
|
||||
|
||||
for _, param := range eval.Parameters {
|
||||
templateVersionParameters = append(templateVersionParameters, param.TemplateVersionParameter())
|
||||
}
|
||||
}
|
||||
|
||||
buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -24,6 +24,309 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestCreateDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
first := coderdtest.CreateFirstUser(t, owner)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
|
||||
|
||||
// Terraform template with conditional parameters.
|
||||
// The "region" parameter only appears when "enable_region" is true.
|
||||
const conditionalParamTF = `
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
data "coder_parameter" "enable_region" {
|
||||
name = "enable_region"
|
||||
order = 1
|
||||
type = "bool"
|
||||
default = "false"
|
||||
}
|
||||
data "coder_parameter" "region" {
|
||||
name = "region"
|
||||
count = data.coder_parameter.enable_region.value == "true" ? 1 : 0
|
||||
order = 2
|
||||
type = "string"
|
||||
# No default - this makes it required when it appears
|
||||
}
|
||||
`
|
||||
|
||||
// Test conditional parameters: a parameter that only appears when another
|
||||
// parameter has a certain value.
|
||||
t.Run("ConditionalParam", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
|
||||
MainTF: conditionalParamTF,
|
||||
})
|
||||
|
||||
// Test 1: Create without enabling region - region param should not exist
|
||||
args := []string{
|
||||
"create", "ws-no-region",
|
||||
"--template", template.Name,
|
||||
"--parameter", "enable_region=false",
|
||||
"-y",
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
doneChan := make(chan error)
|
||||
go func() {
|
||||
doneChan <- inv.Run()
|
||||
}()
|
||||
|
||||
pty.ExpectMatchContext(ctx, "has been created")
|
||||
err := testutil.RequireReceive(ctx, t, doneChan)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify workspace created with only enable_region parameter
|
||||
ws, err := member.WorkspaceByOwnerAndName(t.Context(), codersdk.Me, "ws-no-region", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
buildParams, err := member.WorkspaceBuildParameters(t.Context(), ws.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buildParams, 1, "expected only enable_region parameter when enable_region=false")
|
||||
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "enable_region", Value: "false"})
|
||||
|
||||
// Test 2: Create with region enabled - region param should exist
|
||||
args = []string{
|
||||
"create", "ws-with-region",
|
||||
"--template", template.Name,
|
||||
"--parameter", "enable_region=true",
|
||||
"--parameter", "region=us-east",
|
||||
"-y",
|
||||
}
|
||||
inv, root = clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty = ptytest.New(t).Attach(inv)
|
||||
|
||||
doneChan = make(chan error)
|
||||
go func() {
|
||||
doneChan <- inv.Run()
|
||||
}()
|
||||
|
||||
pty.ExpectMatchContext(ctx, "has been created")
|
||||
|
||||
err = testutil.RequireReceive(ctx, t, doneChan)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify workspace created with both parameters
|
||||
ws, err = member.WorkspaceByOwnerAndName(t.Context(), codersdk.Me, "ws-with-region", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
buildParams, err = member.WorkspaceBuildParameters(t.Context(), ws.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buildParams, 2, "expected both enable_region and region parameters when enable_region=true")
|
||||
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "enable_region", Value: "true"})
|
||||
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "region", Value: "us-east"})
|
||||
})
|
||||
|
||||
// Test that the CLI prompts for missing conditional parameters.
|
||||
// When enable_region=true, the region parameter becomes required and CLI should prompt.
|
||||
t.Run("PromptForConditionalParam", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
|
||||
MainTF: conditionalParamTF,
|
||||
})
|
||||
|
||||
// Only provide enable_region=true, don't provide region - CLI should prompt for it
|
||||
args := []string{
|
||||
"create", "ws-prompted",
|
||||
"--template", template.Name,
|
||||
"--parameter", "enable_region=true",
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
doneChan := make(chan error)
|
||||
go func() {
|
||||
doneChan <- inv.Run()
|
||||
}()
|
||||
|
||||
// CLI should prompt for the region parameter since enable_region=true
|
||||
pty.ExpectMatchContext(ctx, "region")
|
||||
pty.WriteLine("eu-west")
|
||||
|
||||
// Confirm creation
|
||||
pty.ExpectMatchContext(ctx, "Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
|
||||
pty.ExpectMatchContext(ctx, "has been created")
|
||||
|
||||
err := <-doneChan
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify workspace created with both parameters
|
||||
ws, err := member.WorkspaceByOwnerAndName(t.Context(), codersdk.Me, "ws-prompted", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
buildParams, err := member.WorkspaceBuildParameters(t.Context(), ws.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buildParams, 2, "expected both enable_region and region parameters")
|
||||
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "enable_region", Value: "true"})
|
||||
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "region", Value: "eu-west"})
|
||||
})
|
||||
|
||||
// Test that updating a template with a new required parameter causes start to fail
|
||||
// when the user doesn't provide the new parameter value.
|
||||
t.Run("UpdateTemplateRequiredParamStartFails", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Initial template with just enable_region parameter (no default, so required)
|
||||
const initialTF = `
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
data "coder_parameter" "enable_region" {
|
||||
name = "enable_region"
|
||||
type = "bool"
|
||||
}
|
||||
`
|
||||
|
||||
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
|
||||
MainTF: initialTF,
|
||||
})
|
||||
|
||||
// Create workspace with initial template
|
||||
inv, root := clitest.New(t, "create", "ws-update-test",
|
||||
"--template", template.Name,
|
||||
"--parameter", "enable_region=false",
|
||||
"-y",
|
||||
)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Stop the workspace
|
||||
inv, root = clitest.New(t, "stop", "ws-update-test", "-y")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
const updatedTF = `
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
data "coder_parameter" "enable_region" {
|
||||
name = "enable_region"
|
||||
type = "bool"
|
||||
}
|
||||
data "coder_parameter" "region" {
|
||||
count = data.coder_parameter.enable_region.value == "true" ? 1 : 0
|
||||
name = "region"
|
||||
type = "string"
|
||||
# No default - required when enable_region is true
|
||||
}
|
||||
`
|
||||
|
||||
coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
|
||||
MainTF: updatedTF,
|
||||
TemplateID: template.ID,
|
||||
})
|
||||
|
||||
// Try to start the workspace with update - should fail because region is now required
|
||||
// (enable_region defaults to true, making region appear, but no value provided)
|
||||
// and we're using -y to skip prompts
|
||||
inv, root = clitest.New(t, "start", "ws-update-test", "-y", "--parameter", "enable_region=true")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
err = inv.Run()
|
||||
require.Error(t, err, "start should fail because new required parameter 'region' is missing")
|
||||
require.Contains(t, err.Error(), "region")
|
||||
})
|
||||
|
||||
// Test that dynamic validation allows values that would be invalid with static validation.
|
||||
// A slider's max value is determined by another parameter, so a value of 8 is invalid
|
||||
// when max_slider=5, but valid when max_slider=10.
|
||||
t.Run("DynamicValidation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Template where slider's max is controlled by another parameter
|
||||
const dynamicValidationTF = `
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
data "coder_parameter" "max_slider" {
|
||||
name = "max_slider"
|
||||
type = "number"
|
||||
default = 5
|
||||
}
|
||||
data "coder_parameter" "slider" {
|
||||
name = "slider"
|
||||
type = "number"
|
||||
default = 1
|
||||
validation {
|
||||
min = 1
|
||||
max = data.coder_parameter.max_slider.value
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
|
||||
MainTF: dynamicValidationTF,
|
||||
})
|
||||
|
||||
// Test 1: slider=8 should fail when max_slider=5 (default)
|
||||
inv, root := clitest.New(t, "create", "ws-validation-fail",
|
||||
"--template", template.Name,
|
||||
"--parameter", "slider=8",
|
||||
"-y",
|
||||
)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
err := inv.Run()
|
||||
require.Error(t, err, "slider=8 should fail when max_slider=5")
|
||||
|
||||
// Test 2: slider=8 should succeed when max_slider=10
|
||||
inv, root = clitest.New(t, "create", "ws-validation-pass",
|
||||
"--template", template.Name,
|
||||
"--parameter", "max_slider=10",
|
||||
"--parameter", "slider=8",
|
||||
"-y",
|
||||
)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
doneChan := make(chan error)
|
||||
go func() {
|
||||
doneChan <- inv.Run()
|
||||
}()
|
||||
|
||||
pty.ExpectMatchContext(ctx, "has been created")
|
||||
|
||||
err = <-doneChan
|
||||
require.NoError(t, err, "slider=8 should succeed when max_slider=10")
|
||||
|
||||
// Verify workspace created with correct parameters
|
||||
ws, err := member.WorkspaceByOwnerAndName(t.Context(), codersdk.Me, "ws-validation-pass", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
buildParams, err := member.WorkspaceBuildParameters(t.Context(), ws.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "max_slider", Value: "10"})
|
||||
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "slider", Value: "8"})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
|
||||
@@ -719,6 +719,7 @@ func (r *RootCmd) scaletestCreateWorkspaces() *serpent.Command {
|
||||
Action: WorkspaceCreate,
|
||||
TemplateVersionID: tpl.ActiveVersionID,
|
||||
NewWorkspaceName: "scaletest-N", // TODO: the scaletest runner will pass in a different name here. Does this matter?
|
||||
Owner: codersdk.Me,
|
||||
|
||||
RichParameterFile: parameterFlags.richParameterFile,
|
||||
RichParameters: cliRichParameters,
|
||||
@@ -1065,6 +1066,7 @@ func (r *RootCmd) scaletestWorkspaceUpdates() *serpent.Command {
|
||||
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
|
||||
Action: WorkspaceCreate,
|
||||
TemplateVersionID: tpl.ActiveVersionID,
|
||||
Owner: codersdk.Me,
|
||||
|
||||
RichParameterFile: parameterFlags.richParameterFile,
|
||||
RichParameters: cliRichParameters,
|
||||
@@ -1786,6 +1788,7 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
|
||||
Action: WorkspaceCreate,
|
||||
TemplateVersionID: tpl.ActiveVersionID,
|
||||
Owner: codersdk.Me,
|
||||
|
||||
RichParameterFile: parameterFlags.richParameterFile,
|
||||
RichParameters: cliRichParameters,
|
||||
|
||||
@@ -108,8 +108,8 @@ func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCL
|
||||
|
||||
staged = pr.resolveWithParametersMapFile(staged)
|
||||
staged = pr.resolveWithCommandLineOrEnv(staged)
|
||||
staged = pr.resolveWithSourceBuildParameters(staged, templateVersionParameters)
|
||||
staged = pr.resolveWithLastBuildParameters(staged, templateVersionParameters)
|
||||
staged = pr.resolveWithSourceBuildParametersInParameters(staged, templateVersionParameters)
|
||||
staged = pr.resolveWithLastBuildParametersInParameters(staged, templateVersionParameters)
|
||||
staged = pr.resolveWithPreset(staged) // Preset parameters take precedence from all other parameters
|
||||
if err = pr.verifyConstraints(staged, action, templateVersionParameters); err != nil {
|
||||
return nil, err
|
||||
@@ -120,6 +120,18 @@ func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCL
|
||||
return staged, nil
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) InitialValues() []codersdk.WorkspaceBuildParameter {
|
||||
var staged []codersdk.WorkspaceBuildParameter
|
||||
|
||||
staged = pr.resolveWithParametersMapFile(staged)
|
||||
staged = pr.resolveWithCommandLineOrEnv(staged)
|
||||
staged = pr.resolveWithSourceBuildParameters(staged)
|
||||
staged = pr.resolveWithLastBuildParameters(staged)
|
||||
staged = pr.resolveWithPreset(staged) // Preset parameters take precedence from all other parameters
|
||||
|
||||
return staged
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) resolveWithPreset(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
|
||||
next:
|
||||
for _, presetParameter := range pr.presetParameters {
|
||||
@@ -180,7 +192,26 @@ nextEphemeralParameter:
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) resolveWithLastBuildParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter {
|
||||
func (pr *ParameterResolver) resolveWithLastBuildParameters(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
|
||||
if pr.promptRichParameters {
|
||||
return resolved // don't pull parameters from last build
|
||||
}
|
||||
|
||||
next:
|
||||
for _, buildParameter := range pr.lastBuildParameters {
|
||||
for i, r := range resolved {
|
||||
if r.Name == buildParameter.Name {
|
||||
resolved[i].Value = buildParameter.Value
|
||||
continue next
|
||||
}
|
||||
}
|
||||
|
||||
resolved = append(resolved, buildParameter)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) resolveWithLastBuildParametersInParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter {
|
||||
if pr.promptRichParameters {
|
||||
return resolved // don't pull parameters from last build
|
||||
}
|
||||
@@ -216,7 +247,22 @@ next:
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) resolveWithSourceBuildParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter {
|
||||
func (pr *ParameterResolver) resolveWithSourceBuildParameters(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
|
||||
next:
|
||||
for _, buildParameter := range pr.sourceWorkspaceParameters {
|
||||
for i, r := range resolved {
|
||||
if r.Name == buildParameter.Name {
|
||||
resolved[i].Value = buildParameter.Value
|
||||
continue next
|
||||
}
|
||||
}
|
||||
|
||||
resolved = append(resolved, buildParameter)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) resolveWithSourceBuildParametersInParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter {
|
||||
next:
|
||||
for _, buildParameter := range pr.sourceWorkspaceParameters {
|
||||
tvp := findTemplateVersionParameter(buildParameter, templateVersionParameters)
|
||||
|
||||
@@ -152,6 +152,7 @@ func buildWorkspaceStartRequest(inv *serpent.Invocation, client *codersdk.Client
|
||||
TemplateVersionID: version,
|
||||
NewWorkspaceName: workspace.Name,
|
||||
LastBuildParameters: lastBuildParameters,
|
||||
Owner: workspace.OwnerID.String(),
|
||||
|
||||
PromptEphemeralParameters: parameterFlags.promptEphemeralParameters,
|
||||
EphemeralParameters: ephemeralParameters,
|
||||
|
||||
+12
-12
@@ -413,13 +413,13 @@ func TestUpdateValidateRichParameters(t *testing.T) {
|
||||
}()
|
||||
|
||||
pty.ExpectMatch(stringParameterName)
|
||||
pty.ExpectMatch("> Enter a value (default: \"\"): ")
|
||||
pty.ExpectMatch("> Enter a value: ")
|
||||
pty.WriteLine("$$")
|
||||
pty.ExpectMatch("does not match")
|
||||
pty.ExpectMatch("> Enter a value (default: \"\"): ")
|
||||
pty.WriteLine("")
|
||||
pty.ExpectMatch("> Enter a value: ")
|
||||
pty.WriteLine("ABC")
|
||||
pty.ExpectMatch("does not match")
|
||||
pty.ExpectMatch("> Enter a value (default: \"\"): ")
|
||||
pty.ExpectMatch("> Enter a value: ")
|
||||
pty.WriteLine("abc")
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
})
|
||||
@@ -459,13 +459,13 @@ func TestUpdateValidateRichParameters(t *testing.T) {
|
||||
}()
|
||||
|
||||
pty.ExpectMatch(numberParameterName)
|
||||
pty.ExpectMatch("> Enter a value (default: \"\"): ")
|
||||
pty.ExpectMatch("> Enter a value: ")
|
||||
pty.WriteLine("12")
|
||||
pty.ExpectMatch("is more than the maximum")
|
||||
pty.ExpectMatch("> Enter a value (default: \"\"): ")
|
||||
pty.WriteLine("")
|
||||
pty.ExpectMatch("> Enter a value: ")
|
||||
pty.WriteLine("notanumber")
|
||||
pty.ExpectMatch("is not a number")
|
||||
pty.ExpectMatch("> Enter a value (default: \"\"): ")
|
||||
pty.ExpectMatch("> Enter a value: ")
|
||||
pty.WriteLine("8")
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
})
|
||||
@@ -505,13 +505,13 @@ func TestUpdateValidateRichParameters(t *testing.T) {
|
||||
}()
|
||||
|
||||
pty.ExpectMatch(boolParameterName)
|
||||
pty.ExpectMatch("> Enter a value (default: \"\"): ")
|
||||
pty.ExpectMatch("> Enter a value: ")
|
||||
pty.WriteLine("cat")
|
||||
pty.ExpectMatch("boolean value can be either \"true\" or \"false\"")
|
||||
pty.ExpectMatch("> Enter a value (default: \"\"): ")
|
||||
pty.WriteLine("")
|
||||
pty.ExpectMatch("> Enter a value: ")
|
||||
pty.WriteLine("dog")
|
||||
pty.ExpectMatch("boolean value can be either \"true\" or \"false\"")
|
||||
pty.ExpectMatch("> Enter a value (default: \"\"): ")
|
||||
pty.ExpectMatch("> Enter a value: ")
|
||||
pty.WriteLine("false")
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
})
|
||||
|
||||
@@ -249,17 +249,11 @@ 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
|
||||
|
||||
+19
-56
@@ -37,6 +37,25 @@ 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))
|
||||
@@ -64,62 +83,6 @@ 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,236 +1132,6 @@ 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
+39
-8
@@ -3482,6 +3482,45 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"/organizations/{organization}/members/{user}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Members"
|
||||
],
|
||||
"summary": "Get organization member",
|
||||
"operationId": "get-organization-member",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Organization ID",
|
||||
"name": "organization",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.OrganizationMemberWithUserData"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
@@ -20774,14 +20813,6 @@ const docTemplate = `{
|
||||
}
|
||||
]
|
||||
},
|
||||
"subagent_id": {
|
||||
"format": "uuid",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/uuid.NullUUID"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace_folder": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
Generated
+35
-8
@@ -3059,6 +3059,41 @@
|
||||
}
|
||||
},
|
||||
"/organizations/{organization}/members/{user}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Members"],
|
||||
"summary": "Get organization member",
|
||||
"operationId": "get-organization-member",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Organization ID",
|
||||
"name": "organization",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.OrganizationMemberWithUserData"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
@@ -19082,14 +19117,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"subagent_id": {
|
||||
"format": "uuid",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/uuid.NullUUID"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace_folder": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -1228,6 +1228,7 @@ func New(options *Options) *API {
|
||||
r.Use(
|
||||
httpmw.ExtractOrganizationMemberParam(options.Database),
|
||||
)
|
||||
r.Get("/", api.organizationMember)
|
||||
r.Delete("/", api.deleteOrganizationMember)
|
||||
r.Put("/roles", api.putMemberRoles)
|
||||
r.Post("/workspaces", api.postWorkspacesByOrganization)
|
||||
|
||||
@@ -62,7 +62,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/connectionlog"
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbrollup"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
@@ -86,6 +85,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/usage"
|
||||
"github.com/coder/coder/v2/coderd/util/namesgenerator"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/coderd/webpush"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
@@ -934,7 +934,7 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI
|
||||
return role.Name
|
||||
}
|
||||
|
||||
user, err = client.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: db2sdk.List(siteRoles, onlyName)})
|
||||
user, err = client.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: slice.List(siteRoles, onlyName)})
|
||||
require.NoError(t, err, "update site roles")
|
||||
|
||||
// isMember keeps track of which orgs the user was added to as a member
|
||||
@@ -953,7 +953,7 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI
|
||||
}
|
||||
|
||||
_, err = client.UpdateOrganizationMemberRoles(context.Background(), orgID, user.ID.String(),
|
||||
codersdk.UpdateRoles{Roles: db2sdk.List(roles, onlyName)})
|
||||
codersdk.UpdateRoles{Roles: slice.List(roles, onlyName)})
|
||||
require.NoError(t, err, "update org membership roles")
|
||||
isMember[orgID] = true
|
||||
}
|
||||
|
||||
@@ -31,16 +31,6 @@ import (
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
)
|
||||
|
||||
// Deprecated: use slice.List
|
||||
func List[F any, T any](list []F, convert func(F) T) []T {
|
||||
return slice.List[F, T](list, convert)
|
||||
}
|
||||
|
||||
// Deprecated: use slice.ListLazy
|
||||
func ListLazy[F any, T any](convert func(F) T) func(list []F) []T {
|
||||
return slice.ListLazy[F, T](convert)
|
||||
}
|
||||
|
||||
func APIAllowListTarget(entry rbac.AllowListElement) codersdk.APIAllowListTarget {
|
||||
return codersdk.APIAllowListTarget{
|
||||
Type: codersdk.RBACResource(entry.Type),
|
||||
@@ -81,7 +71,7 @@ func WorkspaceBuildParameter(p database.WorkspaceBuildParameter) codersdk.Worksp
|
||||
}
|
||||
|
||||
func WorkspaceBuildParameters(params []database.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
|
||||
return List(params, WorkspaceBuildParameter)
|
||||
return slice.List(params, WorkspaceBuildParameter)
|
||||
}
|
||||
|
||||
func TemplateVersionParameters(params []database.TemplateVersionParameter) ([]codersdk.TemplateVersionParameter, error) {
|
||||
@@ -115,7 +105,7 @@ func TemplateVersionParameterFromPreview(param previewtypes.Parameter) (codersdk
|
||||
Icon: param.Icon,
|
||||
Required: param.Required,
|
||||
Ephemeral: param.Ephemeral,
|
||||
Options: List(param.Options, TemplateVersionParameterOptionFromPreview),
|
||||
Options: slice.List(param.Options, TemplateVersionParameterOptionFromPreview),
|
||||
// Validation set after
|
||||
}
|
||||
if len(param.Validations) > 0 {
|
||||
@@ -237,11 +227,11 @@ func ReducedUserFromGroupMember(member database.GroupMember) codersdk.ReducedUse
|
||||
}
|
||||
|
||||
func ReducedUsersFromGroupMembers(members []database.GroupMember) []codersdk.ReducedUser {
|
||||
return List(members, ReducedUserFromGroupMember)
|
||||
return slice.List(members, ReducedUserFromGroupMember)
|
||||
}
|
||||
|
||||
func ReducedUsers(users []database.User) []codersdk.ReducedUser {
|
||||
return List(users, ReducedUser)
|
||||
return slice.List(users, ReducedUser)
|
||||
}
|
||||
|
||||
func User(user database.User, organizationIDs []uuid.UUID) codersdk.User {
|
||||
@@ -255,7 +245,7 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User {
|
||||
}
|
||||
|
||||
func Users(users []database.User, organizationIDs map[uuid.UUID][]uuid.UUID) []codersdk.User {
|
||||
return List(users, func(user database.User) codersdk.User {
|
||||
return slice.List(users, func(user database.User) codersdk.User {
|
||||
return User(user, organizationIDs[user.ID])
|
||||
})
|
||||
}
|
||||
@@ -388,7 +378,7 @@ func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) cod
|
||||
}
|
||||
|
||||
func OAuth2ProviderApps(accessURL *url.URL, dbApps []database.OAuth2ProviderApp) []codersdk.OAuth2ProviderApp {
|
||||
return List(dbApps, func(dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
|
||||
return slice.List(dbApps, func(dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
|
||||
return OAuth2ProviderApp(accessURL, dbApp)
|
||||
})
|
||||
}
|
||||
@@ -607,7 +597,7 @@ func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus
|
||||
}
|
||||
|
||||
func WorkspaceAppStatuses(statuses []database.WorkspaceAppStatus) []codersdk.WorkspaceAppStatus {
|
||||
return List(statuses, WorkspaceAppStatus)
|
||||
return slice.List(statuses, WorkspaceAppStatus)
|
||||
}
|
||||
|
||||
func WorkspaceAppStatus(status database.WorkspaceAppStatus) codersdk.WorkspaceAppStatus {
|
||||
@@ -728,10 +718,10 @@ func RBACRole(role rbac.Role) codersdk.Role {
|
||||
Name: slim.Name,
|
||||
OrganizationID: slim.OrganizationID,
|
||||
DisplayName: slim.DisplayName,
|
||||
SitePermissions: List(role.Site, RBACPermission),
|
||||
UserPermissions: List(role.User, RBACPermission),
|
||||
OrganizationPermissions: List(orgPerms.Org, RBACPermission),
|
||||
OrganizationMemberPermissions: List(orgPerms.Member, RBACPermission),
|
||||
SitePermissions: slice.List(role.Site, RBACPermission),
|
||||
UserPermissions: slice.List(role.User, RBACPermission),
|
||||
OrganizationPermissions: slice.List(orgPerms.Org, RBACPermission),
|
||||
OrganizationMemberPermissions: slice.List(orgPerms.Member, RBACPermission),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,9 +735,9 @@ func Role(role database.CustomRole) codersdk.Role {
|
||||
Name: role.Name,
|
||||
OrganizationID: orgID,
|
||||
DisplayName: role.DisplayName,
|
||||
SitePermissions: List(role.SitePermissions, Permission),
|
||||
UserPermissions: List(role.UserPermissions, Permission),
|
||||
OrganizationPermissions: List(role.OrgPermissions, Permission),
|
||||
SitePermissions: slice.List(role.SitePermissions, Permission),
|
||||
UserPermissions: slice.List(role.UserPermissions, Permission),
|
||||
OrganizationPermissions: slice.List(role.OrgPermissions, Permission),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,7 +773,7 @@ func Organization(organization database.Organization) codersdk.Organization {
|
||||
}
|
||||
|
||||
func CryptoKeys(keys []database.CryptoKey) []codersdk.CryptoKey {
|
||||
return List(keys, CryptoKey)
|
||||
return slice.List(keys, CryptoKey)
|
||||
}
|
||||
|
||||
func CryptoKey(key database.CryptoKey) codersdk.CryptoKey {
|
||||
@@ -894,8 +884,8 @@ func PreviewParameter(param previewtypes.Parameter) codersdk.PreviewParameter {
|
||||
Mutable: param.Mutable,
|
||||
DefaultValue: PreviewHCLString(param.DefaultValue),
|
||||
Icon: param.Icon,
|
||||
Options: List(param.Options, PreviewParameterOption),
|
||||
Validations: List(param.Validations, PreviewParameterValidation),
|
||||
Options: slice.List(param.Options, PreviewParameterOption),
|
||||
Validations: slice.List(param.Validations, PreviewParameterValidation),
|
||||
Required: param.Required,
|
||||
Order: param.Order,
|
||||
Ephemeral: param.Ephemeral,
|
||||
@@ -911,7 +901,7 @@ func HCLDiagnostics(d hcl.Diagnostics) []codersdk.FriendlyDiagnostic {
|
||||
|
||||
func PreviewDiagnostics(d previewtypes.Diagnostics) []codersdk.FriendlyDiagnostic {
|
||||
f := d.FriendlyDiagnostics()
|
||||
return List(f, func(f previewtypes.FriendlyDiagnostic) codersdk.FriendlyDiagnostic {
|
||||
return slice.List(f, func(f previewtypes.FriendlyDiagnostic) codersdk.FriendlyDiagnostic {
|
||||
return codersdk.FriendlyDiagnostic{
|
||||
Severity: codersdk.DiagnosticSeverityString(f.Severity),
|
||||
Summary: f.Summary,
|
||||
@@ -959,17 +949,17 @@ func PreviewParameterValidation(v *previewtypes.ParameterValidation) codersdk.Pr
|
||||
}
|
||||
|
||||
func AIBridgeInterception(interception database.AIBridgeInterception, initiator database.VisibleUser, tokenUsages []database.AIBridgeTokenUsage, userPrompts []database.AIBridgeUserPrompt, toolUsages []database.AIBridgeToolUsage) codersdk.AIBridgeInterception {
|
||||
sdkTokenUsages := List(tokenUsages, AIBridgeTokenUsage)
|
||||
sdkTokenUsages := slice.List(tokenUsages, AIBridgeTokenUsage)
|
||||
sort.Slice(sdkTokenUsages, func(i, j int) bool {
|
||||
// created_at ASC
|
||||
return sdkTokenUsages[i].CreatedAt.Before(sdkTokenUsages[j].CreatedAt)
|
||||
})
|
||||
sdkUserPrompts := List(userPrompts, AIBridgeUserPrompt)
|
||||
sdkUserPrompts := slice.List(userPrompts, AIBridgeUserPrompt)
|
||||
sort.Slice(sdkUserPrompts, func(i, j int) bool {
|
||||
// created_at ASC
|
||||
return sdkUserPrompts[i].CreatedAt.Before(sdkUserPrompts[j].CreatedAt)
|
||||
})
|
||||
sdkToolUsages := List(toolUsages, AIBridgeToolUsage)
|
||||
sdkToolUsages := slice.List(toolUsages, AIBridgeToolUsage)
|
||||
sort.Slice(sdkToolUsages, func(i, j int) bool {
|
||||
// created_at ASC
|
||||
return sdkToolUsages[i].CreatedAt.Before(sdkToolUsages[j].CreatedAt)
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -227,10 +227,10 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
Name: "test-role",
|
||||
DisplayName: "",
|
||||
OrganizationID: uuid.NullUUID{UUID: tc.organizationID, Valid: true},
|
||||
SitePermissions: db2sdk.List(tc.site, convertSDKPerm),
|
||||
OrgPermissions: db2sdk.List(tc.org, convertSDKPerm),
|
||||
UserPermissions: db2sdk.List(tc.user, convertSDKPerm),
|
||||
MemberPermissions: db2sdk.List(tc.member, convertSDKPerm),
|
||||
SitePermissions: slice.List(tc.site, convertSDKPerm),
|
||||
OrgPermissions: slice.List(tc.org, convertSDKPerm),
|
||||
UserPermissions: slice.List(tc.user, convertSDKPerm),
|
||||
MemberPermissions: slice.List(tc.member, convertSDKPerm),
|
||||
})
|
||||
if tc.errorContains != "" {
|
||||
require.ErrorContains(t, err, tc.errorContains)
|
||||
|
||||
@@ -5726,19 +5726,6 @@ 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 {
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
@@ -1630,11 +1629,11 @@ func (s *MethodTestSuite) TestUser() {
|
||||
Name: "",
|
||||
OrganizationID: uuid.NullUUID{UUID: uuid.Nil, Valid: false},
|
||||
DisplayName: "Test Name",
|
||||
SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
SitePermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionDelete, codersdk.ActionViewInsights},
|
||||
}), convertSDKPerm),
|
||||
OrgPermissions: nil,
|
||||
UserPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
UserPermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}), convertSDKPerm),
|
||||
}
|
||||
@@ -1646,7 +1645,7 @@ func (s *MethodTestSuite) TestUser() {
|
||||
Name: "name",
|
||||
DisplayName: "Test Name",
|
||||
OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true},
|
||||
OrgPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
OrgPermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead},
|
||||
}), convertSDKPerm),
|
||||
}
|
||||
@@ -1668,11 +1667,11 @@ func (s *MethodTestSuite) TestUser() {
|
||||
arg := database.InsertCustomRoleParams{
|
||||
Name: "test",
|
||||
DisplayName: "Test Name",
|
||||
SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
SitePermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionDelete, codersdk.ActionViewInsights},
|
||||
}), convertSDKPerm),
|
||||
OrgPermissions: nil,
|
||||
UserPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
UserPermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}), convertSDKPerm),
|
||||
}
|
||||
@@ -1684,7 +1683,7 @@ func (s *MethodTestSuite) TestUser() {
|
||||
Name: "test",
|
||||
DisplayName: "Test Name",
|
||||
OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true},
|
||||
OrgPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
OrgPermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead},
|
||||
}), convertSDKPerm),
|
||||
}
|
||||
@@ -1930,17 +1929,6 @@ 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,14 +3909,6 @@ 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,20 +7321,6 @@ 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,7 +738,6 @@ 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
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
@@ -2022,8 +2021,8 @@ func TestWorkspaceQuotas(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.ElementsMatch(t, db2sdk.List(everyoneMembers, groupMemberIDs),
|
||||
db2sdk.List([]database.OrganizationMember{memOne, memTwo}, orgMemberIDs))
|
||||
require.ElementsMatch(t, slice.List(everyoneMembers, groupMemberIDs),
|
||||
slice.List([]database.OrganizationMember{memOne, memTwo}, orgMemberIDs))
|
||||
|
||||
// Check the quota is correct.
|
||||
allowance, err := db.GetQuotaAllowanceForUser(ctx, database.GetQuotaAllowanceForUserParams{
|
||||
@@ -2204,7 +2203,7 @@ func TestReadCustomRoles(t *testing.T) {
|
||||
{
|
||||
Name: "AllRolesByLookup",
|
||||
Params: database.CustomRolesParams{
|
||||
LookupRoles: db2sdk.List(allRoles, roleToLookup),
|
||||
LookupRoles: slice.List(allRoles, roleToLookup),
|
||||
},
|
||||
Match: func(role database.CustomRole) bool {
|
||||
return true
|
||||
@@ -2270,8 +2269,8 @@ func TestReadCustomRoles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
a := db2sdk.List(filtered, normalizedRoleName)
|
||||
b := db2sdk.List(found, normalizedRoleName)
|
||||
a := slice.List(filtered, normalizedRoleName)
|
||||
b := slice.List(found, normalizedRoleName)
|
||||
require.Equal(t, a, b)
|
||||
})
|
||||
}
|
||||
@@ -4260,7 +4259,7 @@ func TestGroupRemovalTrigger(t *testing.T) {
|
||||
require.ElementsMatch(t, []uuid.UUID{
|
||||
orgA.ID, orgB.ID, // Everyone groups
|
||||
groupA1.ID, groupA2.ID, groupB1.ID, groupB2.ID, // Org groups
|
||||
}, db2sdk.List(userGroups, onlyGroupIDs))
|
||||
}, slice.List(userGroups, onlyGroupIDs))
|
||||
|
||||
// Remove the user from org A
|
||||
err = db.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{
|
||||
@@ -4277,7 +4276,7 @@ func TestGroupRemovalTrigger(t *testing.T) {
|
||||
require.ElementsMatch(t, []uuid.UUID{
|
||||
orgB.ID, // Everyone group
|
||||
groupB1.ID, groupB2.ID, // Org groups
|
||||
}, db2sdk.List(userGroups, onlyGroupIDs))
|
||||
}, slice.List(userGroups, onlyGroupIDs))
|
||||
|
||||
// Verify extra user is unchanged
|
||||
extraUserGroups, err := db.GetGroups(ctx, database.GetGroupsParams{
|
||||
@@ -4287,7 +4286,7 @@ func TestGroupRemovalTrigger(t *testing.T) {
|
||||
require.ElementsMatch(t, []uuid.UUID{
|
||||
orgA.ID, orgB.ID, // Everyone groups
|
||||
groupA1.ID, groupA2.ID, groupB1.ID, groupB2.ID, // Org groups
|
||||
}, db2sdk.List(extraUserGroups, onlyGroupIDs))
|
||||
}, slice.List(extraUserGroups, onlyGroupIDs))
|
||||
}
|
||||
|
||||
func TestGetUserStatusCounts(t *testing.T) {
|
||||
|
||||
@@ -19306,26 +19306,6 @@ 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,14 +180,6 @@ SET
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: UpdateWorkspaceAgentDisplayAppsByID :exec
|
||||
UPDATE
|
||||
workspace_agents
|
||||
SET
|
||||
display_apps = $2, updated_at = $3
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: GetWorkspaceAgentLogsAfter :many
|
||||
SELECT
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@ package sdk2db
|
||||
|
||||
import (
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -12,5 +12,5 @@ func ProvisionerDaemonStatus(status codersdk.ProvisionerDaemonStatus) database.P
|
||||
}
|
||||
|
||||
func ProvisionerDaemonStatuses(params []codersdk.ProvisionerDaemonStatus) []database.ProvisionerDaemonStatus {
|
||||
return db2sdk.List(params, ProvisionerDaemonStatus)
|
||||
return slice.List(params, ProvisionerDaemonStatus)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/preview"
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
@@ -27,7 +27,7 @@ func (r *loader) staticRender(ctx context.Context, db database.Store) (*staticRe
|
||||
return nil, xerrors.Errorf("template version parameters: %w", err)
|
||||
}
|
||||
|
||||
params := db2sdk.List(dbTemplateVersionParameters, TemplateVersionParameter)
|
||||
params := slice.List(dbTemplateVersionParameters, TemplateVersionParameter)
|
||||
|
||||
for i, param := range params {
|
||||
// Update the diagnostics to validate the 'default' value.
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/runtimeconfig"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
@@ -202,7 +201,7 @@ func (s AGPLIDPSync) SyncGroups(ctx context.Context, db database.Store, user dat
|
||||
// determine if we have to do any group updates to sync the user's
|
||||
// state.
|
||||
existingGroups := userOrgs[orgID]
|
||||
existingGroupsTyped := db2sdk.List(existingGroups, func(f database.GetGroupsRow) ExpectedGroup {
|
||||
existingGroupsTyped := slice.List(existingGroups, func(f database.GetGroupsRow) ExpectedGroup {
|
||||
return ExpectedGroup{
|
||||
OrganizationID: orgID,
|
||||
GroupID: &f.Group.ID,
|
||||
|
||||
@@ -15,13 +15,13 @@ import (
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/idpsync"
|
||||
"github.com/coder/coder/v2/coderd/runtimeconfig"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -590,7 +590,7 @@ func TestApplyGroupDifference(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// assert
|
||||
found := db2sdk.List(userGroups, func(g database.GetGroupsRow) uuid.UUID {
|
||||
found := slice.List(userGroups, func(g database.GetGroupsRow) uuid.UUID {
|
||||
return g.Group.ID
|
||||
})
|
||||
|
||||
@@ -910,14 +910,14 @@ func (o *orgGroupAssert) Assert(t *testing.T, orgID uuid.UUID, db database.Store
|
||||
})
|
||||
|
||||
if len(o.ExpectedGroupNames) > 0 {
|
||||
found := db2sdk.List(userGroups, func(g database.GetGroupsRow) string {
|
||||
found := slice.List(userGroups, func(g database.GetGroupsRow) string {
|
||||
return g.Group.Name
|
||||
})
|
||||
require.ElementsMatch(t, o.ExpectedGroupNames, found, "user groups by name")
|
||||
require.Len(t, o.ExpectedGroups, 0, "ExpectedGroups should be empty")
|
||||
} else {
|
||||
// Check by ID, recommended
|
||||
found := db2sdk.List(userGroups, func(g database.GetGroupsRow) uuid.UUID {
|
||||
found := slice.List(userGroups, func(g database.GetGroupsRow) uuid.UUID {
|
||||
return g.Group.ID
|
||||
})
|
||||
require.ElementsMatch(t, o.ExpectedGroups, found, "user groups")
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/runtimeconfig"
|
||||
@@ -107,7 +106,7 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u
|
||||
return xerrors.Errorf("failed to get user organizations: %w", err)
|
||||
}
|
||||
|
||||
existingOrgIDs := db2sdk.List(existingOrgs, func(org database.Organization) uuid.UUID {
|
||||
existingOrgIDs := slice.List(existingOrgs, func(org database.Organization) uuid.UUID {
|
||||
return org.ID
|
||||
})
|
||||
|
||||
@@ -127,7 +126,7 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to get expected organizations: %w", err)
|
||||
}
|
||||
finalExpected = db2sdk.List(expectedOrganizations, func(org database.Organization) uuid.UUID {
|
||||
finalExpected = slice.List(expectedOrganizations, func(org database.Organization) uuid.UUID {
|
||||
return org.ID
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/idpsync"
|
||||
"github.com/coder/coder/v2/coderd/runtimeconfig"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
@@ -173,7 +173,7 @@ func TestSyncOrganizations(t *testing.T) {
|
||||
|
||||
// Verify the user only exists in 2 orgs. The one they stayed, and the one they
|
||||
// joined.
|
||||
inIDs := db2sdk.List(orgs, func(org database.Organization) uuid.UUID {
|
||||
inIDs := slice.List(orgs, func(org database.Organization) uuid.UUID {
|
||||
return org.ID
|
||||
})
|
||||
require.ElementsMatch(t, []uuid.UUID{stays.Org.ID, joins.Org.ID}, inIDs)
|
||||
|
||||
+50
-1
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/searchquery"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -144,6 +145,54 @@ func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Get organization member
|
||||
// @ID get-organization-member
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Members
|
||||
// @Param organization path string true "Organization ID"
|
||||
// @Param user path string true "User ID, name, or me"
|
||||
// @Success 200 {object} codersdk.OrganizationMemberWithUserData
|
||||
// @Produce json
|
||||
// @Router /organizations/{organization}/members/{user} [get]
|
||||
func (api *API) organizationMember(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
organization = httpmw.OrganizationParam(r)
|
||||
member = httpmw.OrganizationMemberParam(r)
|
||||
)
|
||||
|
||||
// This is unfortunate to fetch like this, but we need the user table data.
|
||||
// The listing route uses this data format, so it is just easier to reuse the
|
||||
// list query.
|
||||
rows, err := api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{
|
||||
OrganizationID: organization.ID,
|
||||
UserID: member.UserID,
|
||||
IncludeSystem: false,
|
||||
GithubUserID: 0,
|
||||
})
|
||||
if httpapi.Is404Error(err) || len(rows) == 0 {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, rows)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(resp) != 1 {
|
||||
httpapi.InternalServerError(rw, xerrors.Errorf("unexpected organization members, something went wrong"))
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp[0])
|
||||
}
|
||||
|
||||
// @Deprecated use /organizations/{organization}/paginated-members [get]
|
||||
// @Summary List organization members
|
||||
// @ID list-organization-members
|
||||
@@ -370,7 +419,7 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d
|
||||
OrganizationID: m.OrganizationID,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
Roles: db2sdk.List(m.Roles, func(r string) codersdk.SlimRole {
|
||||
Roles: slice.List(m.Roles, func(r string) codersdk.SlimRole {
|
||||
// If it is a built-in role, no lookups are needed.
|
||||
rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: r, OrganizationID: m.OrganizationID})
|
||||
if err == nil {
|
||||
|
||||
+25
-9
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -18,17 +18,33 @@ import (
|
||||
func TestAddMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := coderdtest.New(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, owner)
|
||||
_, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
|
||||
|
||||
t.Run("AlreadyMember", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := coderdtest.New(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, owner)
|
||||
_, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
// Add user to org, even though they already exist
|
||||
// nolint:gocritic // must be an owner to see the user
|
||||
_, err := owner.PostOrganizationMember(ctx, first.OrganizationID, user.Username)
|
||||
require.ErrorContains(t, err, "already an organization member")
|
||||
|
||||
org, err := owner.Organization(ctx, first.OrganizationID)
|
||||
require.NoError(t, err)
|
||||
|
||||
member, err := owner.OrganizationMember(ctx, org.Name, user.Username)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, member.UserID, user.ID)
|
||||
})
|
||||
|
||||
t.Run("Me", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
member, err := owner.OrganizationMember(ctx, first.OrganizationID.String(), codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, member.UserID, first.UserID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,7 +92,7 @@ func TestListMembers(t *testing.T) {
|
||||
require.Len(t, members, 3)
|
||||
require.ElementsMatch(t,
|
||||
[]uuid.UUID{owner.UserID, orgMember.ID, orgAdmin.ID},
|
||||
db2sdk.List(members, onlyIDs))
|
||||
slice.List(members, onlyIDs))
|
||||
})
|
||||
|
||||
t.Run("UserID", func(t *testing.T) {
|
||||
@@ -88,7 +104,7 @@ func TestListMembers(t *testing.T) {
|
||||
require.Len(t, members, 1)
|
||||
require.ElementsMatch(t,
|
||||
[]uuid.UUID{orgMember.ID},
|
||||
db2sdk.List(members, onlyIDs))
|
||||
slice.List(members, onlyIDs))
|
||||
})
|
||||
|
||||
t.Run("IncludeSystem", func(t *testing.T) {
|
||||
@@ -100,7 +116,7 @@ func TestListMembers(t *testing.T) {
|
||||
require.Len(t, members, 4)
|
||||
require.ElementsMatch(t,
|
||||
[]uuid.UUID{owner.UserID, orgMember.ID, orgAdmin.ID, database.PrebuildsSystemUserID},
|
||||
db2sdk.List(members, onlyIDs))
|
||||
slice.List(members, onlyIDs))
|
||||
})
|
||||
|
||||
t.Run("GithubUserID", func(t *testing.T) {
|
||||
@@ -112,7 +128,7 @@ func TestListMembers(t *testing.T) {
|
||||
require.Len(t, members, 1)
|
||||
require.ElementsMatch(t,
|
||||
[]uuid.UUID{anotherUser.ID},
|
||||
db2sdk.List(members, onlyIDs))
|
||||
slice.List(members, onlyIDs))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -32,7 +33,7 @@ func (api *API) organizations(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(organizations, db2sdk.Organization))
|
||||
httpapi.Write(ctx, rw, http.StatusOK, slice.List(organizations, db2sdk.Organization))
|
||||
}
|
||||
|
||||
// @Summary Get organization by ID
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/dynamicparameters"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
"github.com/coder/websocket"
|
||||
@@ -121,7 +122,7 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini
|
||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
||||
}
|
||||
if result != nil {
|
||||
response.Parameters = db2sdk.List(result.Parameters, db2sdk.PreviewParameter)
|
||||
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, response)
|
||||
@@ -155,7 +156,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
|
||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
||||
}
|
||||
if result != nil {
|
||||
response.Parameters = db2sdk.List(result.Parameters, db2sdk.PreviewParameter)
|
||||
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
|
||||
}
|
||||
err = stream.Send(response)
|
||||
if err != nil {
|
||||
@@ -192,7 +193,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
|
||||
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
|
||||
}
|
||||
if result != nil {
|
||||
response.Parameters = db2sdk.List(result.Parameters, db2sdk.PreviewParameter)
|
||||
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
|
||||
}
|
||||
err = stream.Send(response)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -81,7 +82,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(daemons, func(dbDaemon database.GetProvisionerDaemonsWithStatusByOrganizationRow) codersdk.ProvisionerDaemon {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, slice.List(daemons, func(dbDaemon database.GetProvisionerDaemonsWithStatusByOrganizationRow) codersdk.ProvisionerDaemon {
|
||||
pd := db2sdk.ProvisionerDaemon(dbDaemon.ProvisionerDaemon)
|
||||
var currentJob, previousJob *codersdk.ProvisionerDaemonJob
|
||||
if dbDaemon.CurrentJobID.Valid {
|
||||
|
||||
@@ -87,7 +87,7 @@ func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(jobs, convertProvisionerJobWithQueuePosition))
|
||||
httpapi.Write(ctx, rw, http.StatusOK, slice.List(jobs, convertProvisionerJobWithQueuePosition))
|
||||
}
|
||||
|
||||
// handleAuthAndFetchProvisionerJobs is an internal method shared by
|
||||
|
||||
@@ -1389,7 +1389,7 @@ func TestTemplateVersionDryRun(t *testing.T) {
|
||||
// This import job will never finish
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: []*proto.Response{{
|
||||
ProvisionPlan: []*proto.Response{{
|
||||
Type: &proto.Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
|
||||
+2
-2
@@ -1445,7 +1445,7 @@ func (api *API) organizationsByUser(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(organizations, db2sdk.Organization))
|
||||
httpapi.Write(ctx, rw, http.StatusOK, slice.List(organizations, db2sdk.Organization))
|
||||
}
|
||||
|
||||
// @Summary Get organization by user and organization name
|
||||
@@ -1669,6 +1669,6 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey {
|
||||
Scopes: scopes,
|
||||
LifetimeSeconds: k.LifetimeSeconds,
|
||||
TokenName: k.TokenName,
|
||||
AllowList: db2sdk.List(k.AllowList, db2sdk.APIAllowListTarget),
|
||||
AllowList: slice.List(k.AllowList, db2sdk.APIAllowListTarget),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2353,6 +2353,17 @@ func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't allow adding new groups or users to a workspace associated with a
|
||||
// task. Sharing a task workspace without sharing the task itself is a broken
|
||||
// half measure that we don't want to support right now. To be fixed!
|
||||
if workspace.TaskID.Valid {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Task workspaces cannot be shared.",
|
||||
Detail: "This workspace is managed by a task. Task sharing has not yet been implemented.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := httpmw.APIKey(r)
|
||||
if _, ok := req.UserRoles[apiKey.UserID.String()]; ok {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/provisioner/terraform/tfparse"
|
||||
"github.com/coder/coder/v2/provisionersdk"
|
||||
@@ -947,7 +948,7 @@ func (b *Builder) getTemplateVersionParameters() ([]previewtypes.Parameter, erro
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
return nil, xerrors.Errorf("get template version %s parameters: %w", tvID, err)
|
||||
}
|
||||
b.templateVersionParameters = ptr.Ref(db2sdk.List(tvp, dynamicparameters.TemplateVersionParameter))
|
||||
b.templateVersionParameters = ptr.Ref(slice.List(tvp, dynamicparameters.TemplateVersionParameter))
|
||||
return *b.templateVersionParameters, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -425,20 +425,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -451,16 +442,10 @@ 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,7 +136,6 @@ 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()},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
@@ -69,6 +71,54 @@ type PreviewParameter struct {
|
||||
Diagnostics []FriendlyDiagnostic `json:"diagnostics"`
|
||||
}
|
||||
|
||||
func (p PreviewParameter) TemplateVersionParameter() TemplateVersionParameter {
|
||||
tp := TemplateVersionParameter{
|
||||
Name: p.Name,
|
||||
DisplayName: p.DisplayName,
|
||||
Description: p.Description,
|
||||
DescriptionPlaintext: p.Description,
|
||||
Type: string(p.Type),
|
||||
FormType: string(p.FormType),
|
||||
Mutable: p.Mutable,
|
||||
DefaultValue: p.DefaultValue.Value,
|
||||
Icon: p.Icon,
|
||||
Options: slice.List(p.Options, func(o PreviewParameterOption) TemplateVersionParameterOption {
|
||||
return o.TemplateVersionParameterOption()
|
||||
}),
|
||||
Required: p.Required,
|
||||
Ephemeral: p.Ephemeral,
|
||||
}
|
||||
|
||||
if len(p.Validations) > 0 {
|
||||
valid := p.Validations[0]
|
||||
tp.ValidationError = valid.Error
|
||||
if valid.Monotonic != nil {
|
||||
tp.ValidationMonotonic = ValidationMonotonicOrder(*valid.Monotonic)
|
||||
}
|
||||
if valid.Regex != nil {
|
||||
tp.ValidationRegex = *valid.Regex
|
||||
}
|
||||
if valid.Min != nil {
|
||||
//nolint:gosec
|
||||
tp.ValidationMin = ptr.Ref(int32(*valid.Min))
|
||||
}
|
||||
if valid.Max != nil {
|
||||
//nolint:gosec
|
||||
tp.ValidationMax = ptr.Ref(int32(*valid.Max))
|
||||
}
|
||||
}
|
||||
return tp
|
||||
}
|
||||
|
||||
func (o PreviewParameterOption) TemplateVersionParameterOption() TemplateVersionParameterOption {
|
||||
return TemplateVersionParameterOption{
|
||||
Name: o.Name,
|
||||
Description: o.Description,
|
||||
Value: o.Value.Value,
|
||||
Icon: o.Icon,
|
||||
}
|
||||
}
|
||||
|
||||
type PreviewParameterData struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/types/ptr"
|
||||
|
||||
@@ -10,6 +14,26 @@ import (
|
||||
"github.com/coder/terraform-provider-coder/v2/provider"
|
||||
)
|
||||
|
||||
func (c *Client) EvaluateTemplateVersion(ctx context.Context, templateVersionID uuid.UUID, ownerID uuid.UUID, inputs map[string]string) (DynamicParametersResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost,
|
||||
fmt.Sprintf("/api/v2/templateversions/%s/dynamic-parameters/evaluate", templateVersionID),
|
||||
DynamicParametersRequest{
|
||||
ID: 0,
|
||||
Inputs: inputs,
|
||||
OwnerID: ownerID,
|
||||
})
|
||||
if err != nil {
|
||||
return DynamicParametersResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return DynamicParametersResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var dynResp DynamicParametersResponse
|
||||
return dynResp, json.NewDecoder(res.Body).Decode(&dynResp)
|
||||
}
|
||||
|
||||
func ValidateNewWorkspaceParameters(richParameters []TemplateVersionParameter, buildParameters []WorkspaceBuildParameter) error {
|
||||
return ValidateWorkspaceBuildParameters(richParameters, buildParameters, nil)
|
||||
}
|
||||
|
||||
@@ -644,6 +644,19 @@ func OrganizationMembersQueryOptionGithubUserID(githubUserID int64) Organization
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) OrganizationMember(ctx context.Context, organizationIdent, userIdent string) (OrganizationMemberWithUserData, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationIdent, userIdent), nil)
|
||||
if err != nil {
|
||||
return OrganizationMemberWithUserData{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return OrganizationMemberWithUserData{}, ReadBodyAsError(res)
|
||||
}
|
||||
var member OrganizationMemberWithUserData
|
||||
return member, json.NewDecoder(res.Body).Decode(&member)
|
||||
}
|
||||
|
||||
// OrganizationMembers lists all members in an organization
|
||||
func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID, opts ...OrganizationMembersQueryOption) ([]OrganizationMemberWithUserData, error) {
|
||||
var query OrganizationMembersQuery
|
||||
|
||||
@@ -440,11 +440,10 @@ 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"`
|
||||
SubagentID uuid.NullUUID `json:"subagent_id,omitempty" format:"uuid"`
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Name string `json:"name"`
|
||||
WorkspaceFolder string `json:"workspace_folder"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
|
||||
// Additional runtime fields.
|
||||
Status WorkspaceAgentDevcontainerStatus `json:"status"`
|
||||
@@ -459,7 +458,6 @@ 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 ||
|
||||
@@ -469,12 +467,6 @@ 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,173 +110,3 @@ 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ Once enabled, `coderd` runs the `aibridgeproxyd` in-memory and intercepts traffi
|
||||
|
||||
**Required:**
|
||||
|
||||
1. AI Bridge must be enabled and configured (requires a **premium** license). See [AI Bridge Setup](../setup.md) for further information.
|
||||
1. AI Bridge Proxy must be [enabled](#proxy-configuration) using the server flag.
|
||||
1. AI Bridge must be enabled and configured (requires a **Premium** license with the [AI Governance Add-On](../../ai-governance.md)). See [AI Bridge Setup](../setup.md) for further information.1. AI Bridge Proxy must be [enabled](#proxy-configuration) using the server flag.
|
||||
1. A [CA certificate](#ca-certificate) must be configured for MITM interception.
|
||||
1. Clients must be configured to trust the CA certificate and use the proxy.
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ The table below shows tested AI clients and their compatibility with AI Bridge.
|
||||
|
||||
| Client | OpenAI | Anthropic | Notes |
|
||||
|----------------------------------|--------|-----------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [Mux](./mux.md) | ✅ | ✅ | |
|
||||
| [Claude Code](./claude-code.md) | - | ✅ | |
|
||||
| [Codex CLI](./codex.md) | ✅ | - | |
|
||||
| [OpenCode](./opencode.md) | ✅ | ✅ | |
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Mux
|
||||
|
||||
Mux makes it easy to run parallel coding agents, each with its own isolated workspace, from your browser or desktop; it is open source and provider-agnostic. For more background on Mux, see [Coder Research](../../../coder-research.md#mux).
|
||||
|
||||
Mux can be configured to route OpenAI- and Anthropic-compatible traffic through AI Bridge by setting a custom provider base URL and using a Coder-issued token for authentication.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- AI Bridge is enabled on your Coder deployment.
|
||||
- A **[Coder session token](../../../admin/users/sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-yourself)** or long-lived API key.
|
||||
|
||||
## Configuration
|
||||
|
||||
<div class="tabs">
|
||||
|
||||
### OpenAI
|
||||
|
||||
1. Open Mux settings (`Cmd+,` / `Ctrl+,`).
|
||||
2. Go to **Providers** → **OpenAI**.
|
||||
3. Set **API Key** to your Coder session token.
|
||||
4. Set **Base URL** to `https://coder.example.com/api/v2/aibridge/openai/v1`.
|
||||
|
||||
### Anthropic
|
||||
|
||||
1. Open Mux settings (`Cmd+,` / `Ctrl+,`).
|
||||
2. Go to **Providers** → **Anthropic**.
|
||||
3. Set **API Key** to your Coder session token.
|
||||
4. Set **Base URL** to `https://coder.example.com/api/v2/aibridge/anthropic`.
|
||||
|
||||
</div>
|
||||
|
||||
_Replace `coder.example.com` with your Coder deployment URL._
|
||||
|
||||
## Environment variables
|
||||
|
||||
Mux reads provider configuration from its settings UI and also from environment variables.
|
||||
Environment variables are useful in CI or when running Mux inside a Coder workspace.
|
||||
|
||||
> [!NOTE]
|
||||
> Mux treats environment variables as a fallback when a provider is not configured in settings.
|
||||
> If you have already configured a provider in the UI, clear it (or update it) for env vars to take effect.
|
||||
|
||||
```sh
|
||||
# OpenAI-compatible traffic (GPT, Codex, etc.)
|
||||
export OPENAI_API_KEY="<your-coder-session-token>"
|
||||
export OPENAI_BASE_URL="https://coder.example.com/api/v2/aibridge/openai/v1"
|
||||
|
||||
# Anthropic-compatible traffic (Claude, etc.)
|
||||
export ANTHROPIC_API_KEY="<your-coder-session-token>"
|
||||
export ANTHROPIC_BASE_URL="https://coder.example.com/api/v2/aibridge/anthropic"
|
||||
```
|
||||
|
||||
## Running Mux in a Coder workspace
|
||||
|
||||
If you want to run Mux inside a Coder workspace (for example, as a Coder app), you can install it with the [Mux module](https://registry.coder.com/modules/coder/mux) and pre-configure AI Bridge via environment variables on the agent:
|
||||
|
||||
```tf
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_agent" "main" {
|
||||
# ... other agent configuration
|
||||
env = {
|
||||
OPENAI_API_KEY = data.coder_workspace_owner.me.session_token
|
||||
OPENAI_BASE_URL = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
|
||||
ANTHROPIC_API_KEY = data.coder_workspace_owner.me.session_token
|
||||
ANTHROPIC_BASE_URL = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
|
||||
}
|
||||
}
|
||||
|
||||
module "mux" {
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "~> 1.0" # See the module page for the latest version.
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced: providers.jsonc
|
||||
|
||||
If you prefer a file-based config, edit `~/.mux/providers.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"openai": {
|
||||
"apiKey": "<your-coder-session-token>",
|
||||
"baseUrl": "https://coder.example.com/api/v2/aibridge/openai/v1"
|
||||
},
|
||||
"anthropic": {
|
||||
"apiKey": "<your-coder-session-token>",
|
||||
"baseUrl": "https://coder.example.com/api/v2/aibridge/anthropic"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**References:** [Mux provider environment variables](https://mux.coder.com/config/providers#environment-variables)
|
||||
@@ -4,7 +4,7 @@ AI Bridge runs inside the Coder control plane (`coderd`), requiring no separate
|
||||
|
||||
**Required**:
|
||||
|
||||
1. A **premium** licensed Coder deployment
|
||||
1. A **Premium** license with the [AI Governance Add-On](../ai-governance.md).
|
||||
1. Feature must be [enabled](#activation) using the server flag
|
||||
1. One or more [providers](#configure-providers) API key(s) must be configured
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ development environments. As adoption grows, many enterprises also need
|
||||
observability, management, and policy controls to support secure and auditable
|
||||
AI rollouts.
|
||||
|
||||
Coder’s AI Governance Add-On for Premium licenses includes a set of features
|
||||
The AI Governance Add-On is a per-user license that can be added to Premium seats. Each user with the add-on gets access to a set of features
|
||||
that help organizations safely roll out AI tooling at scale:
|
||||
|
||||
- [AI Bridge](./ai-bridge/index.md): LLM gateway to audit AI sessions, central
|
||||
@@ -95,6 +95,11 @@ options, reach out to your
|
||||
|
||||
## How Coder Tasks usage is measured
|
||||
|
||||
> [!NOTE]
|
||||
> There is a known issue with how Agent Workspace Builds are tallied in v2.28
|
||||
> and v2.29. We recommend updating to v2.28.9, v2.29.4, or v2.30 to resolve
|
||||
> this issue.
|
||||
|
||||
The usage metric used to measure Coder Tasks consumption is called **Agent
|
||||
Workspace Builds** (prev. "managed agents").
|
||||
|
||||
@@ -133,8 +138,18 @@ workflows.
|
||||
Our [AI Governance Add-On](./ai-governance.md) includes a shared usage pool of
|
||||
Agent Workspace Builds for automated workflows, along with limits that scale
|
||||
proportionately with user count. Usage counts are measured and sent to Coder via
|
||||
[usage data reporting](./usage-data-reporting.md). Coder Tasks or other AI
|
||||
features do not break when you run over the limit.
|
||||
[usage data reporting](./usage-data-reporting.md). Coder Tasks and other AI
|
||||
features continue to function normally even if the limit is breached. Admins
|
||||
will receive a warning to [contact their account team](https://coder.com/contact)
|
||||
to remediate.
|
||||
|
||||
If you are approaching your deployment-wide limits,
|
||||
[contact us](https://coder.com/contact) to discuss your use case with our team.
|
||||
### Tracking Agent Workspace Builds
|
||||
|
||||
Admins can monitor Agent Workspace Build usage from the Coder dashboard.
|
||||
Navigate to **Deployment** > **Licenses** to view current usage against your
|
||||
entitlement limits.
|
||||
|
||||

|
||||
|
||||
<small>Agent Workspace Build usage showing current consumption against
|
||||
entitlement limits in the Licenses page.</small>
|
||||
|
||||
@@ -31,7 +31,7 @@ terminal-based agent such as Claude Code or Codex's Open Source CLI.
|
||||
[Learn more about Coder Tasks](./tasks.md) for best practices and how to get
|
||||
started.
|
||||
|
||||
## Secure Your Workflows with Agent Boundaries (Beta)
|
||||
## Secure Your Workflows with Agent Boundaries
|
||||
|
||||
AI agents can be powerful teammates, but must be treated as untrusted and
|
||||
unpredictable interns as opposed to tools. Without the right controls, they can
|
||||
|
||||
@@ -9,7 +9,7 @@ Coder maintains several open-source research projects exploring the future of AI
|
||||
### Features
|
||||
|
||||
- **Isolated workspace management**: Run multiple agents in parallel using local execution, git worktrees, or remote SSH without interference
|
||||
- **Multi-model support**: Compatible with Claude (sonnet-4, opus-4), Grok, GPT-5, Ollama for local LLMs, and OpenRouter
|
||||
- **Multi-model support**: Compatible with models from Anthropic, xAI, OpenAI, Ollama for local LLMs, and OpenRouter
|
||||
- **Central git divergence view**: Monitor changes and potential conflicts across agent workspaces from a unified dashboard
|
||||
- **Developer integration**: VS Code extension, Plan/Exec mode, vim input support, and slash commands
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
+6
-1
@@ -996,7 +996,7 @@
|
||||
"title": "Agent Boundaries",
|
||||
"description": "Understanding Agent Boundaries in Coder Tasks",
|
||||
"path": "./ai-coder/agent-boundaries/index.md",
|
||||
"state": ["premium", "beta"],
|
||||
"state": ["premium"],
|
||||
"children": [
|
||||
{
|
||||
"title": "NS Jail",
|
||||
@@ -1047,6 +1047,11 @@
|
||||
"description": "Configure Codex to use AI Bridge",
|
||||
"path": "./ai-coder/ai-bridge/clients/codex.md"
|
||||
},
|
||||
{
|
||||
"title": "Mux",
|
||||
"description": "Configure Mux to use AI Bridge",
|
||||
"path": "./ai-coder/ai-bridge/clients/mux.md"
|
||||
},
|
||||
{
|
||||
"title": "OpenCode",
|
||||
"description": "Configure OpenCode to use AI Bridge",
|
||||
|
||||
Generated
-8
@@ -838,10 +838,6 @@ 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"
|
||||
}
|
||||
],
|
||||
@@ -1019,10 +1015,6 @@ 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
+59
@@ -540,6 +540,65 @@ Status Code **200**
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get organization member
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members/{user} \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /organizations/{organization}/members/{user}`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|----------------|------|--------|----------|----------------------|
|
||||
| `organization` | path | string | true | Organization ID |
|
||||
| `user` | path | string | true | User ID, name, or me |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"avatar_url": "string",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"email": "string",
|
||||
"global_roles": [
|
||||
{
|
||||
"display_name": "string",
|
||||
"name": "string",
|
||||
"organization_id": "string"
|
||||
}
|
||||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"roles": [
|
||||
{
|
||||
"display_name": "string",
|
||||
"name": "string",
|
||||
"organization_id": "string"
|
||||
}
|
||||
],
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OrganizationMemberWithUserData](schemas.md#codersdkorganizationmemberwithuserdata) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Add organization member
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
-9
@@ -10514,10 +10514,6 @@ 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"
|
||||
}
|
||||
```
|
||||
@@ -10534,7 +10530,6 @@ 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
|
||||
@@ -10666,10 +10661,6 @@ 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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -379,6 +379,7 @@ module "mux" {
|
||||
agent_id = coder_agent.dev.id
|
||||
subdomain = true
|
||||
display_name = "Mux"
|
||||
add-project = local.repo_dir
|
||||
}
|
||||
|
||||
module "code-server" {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
@@ -21,6 +20,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/idpsync"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/runtimeconfig"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/enidpsync"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -61,7 +61,7 @@ func TestOrganizationSync(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
foundIDs := db2sdk.List(members, func(m database.OrganizationMembersRow) uuid.UUID {
|
||||
foundIDs := slice.List(members, func(m database.OrganizationMembersRow) uuid.UUID {
|
||||
return m.OrganizationMember.OrganizationID
|
||||
})
|
||||
require.ElementsMatch(t, expected, foundIDs, "match user organizations")
|
||||
|
||||
@@ -12,12 +12,12 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/rolestore"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/license"
|
||||
@@ -893,7 +893,7 @@ func TestGroup(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
foundIDs := db2sdk.List(found, func(g codersdk.Group) uuid.UUID {
|
||||
foundIDs := slice.List(found, func(g codersdk.Group) uuid.UUID {
|
||||
return g.ID
|
||||
})
|
||||
|
||||
@@ -1009,7 +1009,7 @@ func TestGroups(t *testing.T) {
|
||||
// disabled, but group membership is limited to the requesting user.
|
||||
// TODO(geokat): add another test with workspace sharing disabled.
|
||||
require.Len(t, user5View, 3)
|
||||
user5ViewIDs := db2sdk.List(user5View, func(g codersdk.Group) uuid.UUID {
|
||||
user5ViewIDs := slice.List(user5View, func(g codersdk.Group) uuid.UUID {
|
||||
return g.ID
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -62,9 +63,9 @@ func (api *API) postOrgRoles(rw http.ResponseWriter, r *http.Request) {
|
||||
UUID: organization.ID,
|
||||
Valid: true,
|
||||
},
|
||||
SitePermissions: db2sdk.List(req.SitePermissions, sdkPermissionToDB),
|
||||
OrgPermissions: db2sdk.List(req.OrganizationPermissions, sdkPermissionToDB),
|
||||
UserPermissions: db2sdk.List(req.UserPermissions, sdkPermissionToDB),
|
||||
SitePermissions: slice.List(req.SitePermissions, sdkPermissionToDB),
|
||||
OrgPermissions: slice.List(req.OrganizationPermissions, sdkPermissionToDB),
|
||||
UserPermissions: slice.List(req.UserPermissions, sdkPermissionToDB),
|
||||
// Satisfy the linter (we don't support member permissions in non-system roles).
|
||||
MemberPermissions: database.CustomRolePermissions{},
|
||||
IsSystem: false,
|
||||
@@ -154,9 +155,9 @@ func (api *API) putOrgRoles(rw http.ResponseWriter, r *http.Request) {
|
||||
// to throw an error, then the story of a previously valid role
|
||||
// now being invalid has to be addressed. Coder can change permissions,
|
||||
// objects, and actions at any time.
|
||||
SitePermissions: db2sdk.List(filterInvalidPermissions(req.SitePermissions), sdkPermissionToDB),
|
||||
OrgPermissions: db2sdk.List(filterInvalidPermissions(req.OrganizationPermissions), sdkPermissionToDB),
|
||||
UserPermissions: db2sdk.List(filterInvalidPermissions(req.UserPermissions), sdkPermissionToDB),
|
||||
SitePermissions: slice.List(filterInvalidPermissions(req.SitePermissions), sdkPermissionToDB),
|
||||
OrgPermissions: slice.List(filterInvalidPermissions(req.OrganizationPermissions), sdkPermissionToDB),
|
||||
UserPermissions: slice.List(filterInvalidPermissions(req.UserPermissions), sdkPermissionToDB),
|
||||
// Satisfy the linter (we don't support member permissions in non-system roles).
|
||||
MemberPermissions: database.CustomRolePermissions{},
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/license"
|
||||
@@ -63,7 +64,7 @@ func TestCustomOrganizationRole(t *testing.T) {
|
||||
// Changing this might mess up the UI in how it renders the roles on the
|
||||
// users page. When the users endpoint is updated, this should be uncommented.
|
||||
// roleNamesF := func(role codersdk.SlimRole) string { return role.Name }
|
||||
// require.Contains(t, db2sdk.List(user.Roles, roleNamesF), role.Name)
|
||||
// require.Contains(t, slice.List(user.Roles, roleNamesF), role.Name)
|
||||
|
||||
// Try to create a template version
|
||||
coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil)
|
||||
@@ -594,8 +595,8 @@ func TestListRoles(t *testing.T) {
|
||||
BuiltIn: true,
|
||||
}
|
||||
}
|
||||
expected := db2sdk.List(c.ExpectedRoles, ignorePerms)
|
||||
found := db2sdk.List(roles, ignorePerms)
|
||||
expected := slice.List(c.ExpectedRoles, ignorePerms)
|
||||
found := slice.List(roles, ignorePerms)
|
||||
require.ElementsMatch(t, expected, found)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
@@ -1122,7 +1121,7 @@ func (r *oidcTestRunner) AssertOrganizations(t *testing.T, userIdent string, inc
|
||||
cpy := make([]uuid.UUID, 0, len(expected))
|
||||
cpy = append(cpy, expected...)
|
||||
hasDefault := false
|
||||
userOrgIDs := db2sdk.List(userOrgs, func(o codersdk.Organization) uuid.UUID {
|
||||
userOrgIDs := slice.List(userOrgs, func(o codersdk.Organization) uuid.UUID {
|
||||
if o.IsDefault {
|
||||
hasDefault = true
|
||||
cpy = append(cpy, o.ID)
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/license"
|
||||
@@ -56,7 +56,7 @@ func TestEnterpriseMembers(t *testing.T) {
|
||||
require.Len(t, members, 3)
|
||||
require.ElementsMatch(t,
|
||||
[]uuid.UUID{first.UserID, user.ID, orgAdmin.ID},
|
||||
db2sdk.List(members, onlyIDs))
|
||||
slice.List(members, onlyIDs))
|
||||
|
||||
// Add the member to some groups
|
||||
_, err = orgAdminClient.PatchGroup(ctx, g1.ID, codersdk.PatchGroupRequest{
|
||||
@@ -86,7 +86,7 @@ func TestEnterpriseMembers(t *testing.T) {
|
||||
require.Len(t, members, 2)
|
||||
require.ElementsMatch(t,
|
||||
[]uuid.UUID{first.UserID, orgAdmin.ID},
|
||||
db2sdk.List(members, onlyIDs))
|
||||
slice.List(members, onlyIDs))
|
||||
|
||||
// User should now belong to 0 groups
|
||||
userGroups, err = orgAdminClient.Groups(ctx, codersdk.GroupArguments{
|
||||
@@ -130,7 +130,7 @@ func TestEnterpriseMembers(t *testing.T) {
|
||||
require.Len(t, members, 3)
|
||||
require.ElementsMatch(t,
|
||||
[]uuid.UUID{first.UserID, user.ID, userAdmin.ID},
|
||||
db2sdk.List(members, onlyIDs))
|
||||
slice.List(members, onlyIDs))
|
||||
})
|
||||
|
||||
t.Run("PostUserNotExists", func(t *testing.T) {
|
||||
|
||||
@@ -437,7 +437,7 @@ require (
|
||||
go.opentelemetry.io/collector/pdata/pprofile v0.121.0 // indirect
|
||||
go.opentelemetry.io/collector/semconv v0.123.0 // indirect
|
||||
go.opentelemetry.io/contrib v1.19.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
@@ -470,10 +470,11 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
cdr.dev/slog v1.6.2-0.20251120224544-40ff19937ff2
|
||||
github.com/anthropics/anthropic-sdk-go v1.19.0
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.0
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
|
||||
github.com/coder/aibridge v1.0.1-0.20260202135542-9e2857aaac8f
|
||||
github.com/coder/aibridge v1.0.1
|
||||
github.com/coder/aisdk-go v0.0.9
|
||||
github.com/coder/boundary v0.6.1
|
||||
github.com/coder/preview v1.0.4
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
cdr.dev/slog v1.6.2-0.20251120224544-40ff19937ff2 h1:M4Z9eTbnHPdZI4GpBUNCae0lSgUucY+aW5j7+zB8lCk=
|
||||
cdr.dev/slog v1.6.2-0.20251120224544-40ff19937ff2/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ=
|
||||
cdr.dev/slog/v3 v3.0.0-rc1 h1:EN7Zim6GvTpAeHQjI0ERDEfqKbTyXRvgH4UhlzLpvWM=
|
||||
cdr.dev/slog/v3 v3.0.0-rc1/go.mod h1:iO/OALX1VxlI03mkodCGdVP7pXzd2bRMvu3ePvlJ9ak=
|
||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||
@@ -927,8 +929,8 @@ github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
|
||||
github.com/coder/aibridge v1.0.1-0.20260202135542-9e2857aaac8f h1:DaSKB6p/CgDN3RXHESvFbvsT2P9XRZm0th2+MiFImwE=
|
||||
github.com/coder/aibridge v1.0.1-0.20260202135542-9e2857aaac8f/go.mod h1:M1aoiK6qmybTjD2nzcTCRPXzA/I0Ned+MAxUmz4Ju+k=
|
||||
github.com/coder/aibridge v1.0.1 h1:l6MgNVLvyu9EFp/Q00OItymTlGVK16XXT/KfSuDmxBM=
|
||||
github.com/coder/aibridge v1.0.1/go.mod h1:M1aoiK6qmybTjD2nzcTCRPXzA/I0Ned+MAxUmz4Ju+k=
|
||||
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
|
||||
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
|
||||
github.com/coder/boundary v0.6.1 h1:hLnrincIFA8Wak5SrH/xQDIIhkKQpnHVotLwC585z7g=
|
||||
@@ -2037,8 +2039,8 @@ go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
|
||||
go.opentelemetry.io/otel v1.3.0/go.mod h1:PWIKzi6JCp7sM0k9yZ43VX+T345uNbAkDKwHVjb2PTs=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
|
||||
@@ -104,6 +104,17 @@ func testingWithOwnerUser(m dsl.Matcher) {
|
||||
Report(`This client is operating as the owner user, which has unrestricted permissions. Consider creating a different user.`)
|
||||
}
|
||||
|
||||
// doNotUseRawGoInAgent detects raw `go func()` in agent package.
|
||||
// Use agentutil.Go() instead for panic recovery.
|
||||
//
|
||||
//nolint:unused,deadcode,varnamelen
|
||||
func doNotUseRawGoInAgent(m dsl.Matcher) {
|
||||
m.Match(`go func() { $*_ }()`, `go func($*_) { $*_ }($*_)`).
|
||||
Where(m.File().PkgPath.Matches(`github\.com/coder/coder/v2/agent(/.*)?`) &&
|
||||
!m.File().Name.Matches(`_test\.go$`)).
|
||||
Report("Use agentutil.Go() instead of raw go func() for panic recovery")
|
||||
}
|
||||
|
||||
// Use xerrors everywhere! It provides additional stacktrace info!
|
||||
//
|
||||
//nolint:unused,deadcode,varnamelen
|
||||
|
||||
Generated
-1
@@ -6290,7 +6290,6 @@ export interface WorkspaceAgentDevcontainer {
|
||||
readonly name: string;
|
||||
readonly workspace_folder: string;
|
||||
readonly config_path?: string;
|
||||
readonly subagent_id?: string;
|
||||
/**
|
||||
* Additional runtime fields.
|
||||
*/
|
||||
|
||||
@@ -18,8 +18,6 @@ export const Popover = PopoverPrimitive.Root;
|
||||
|
||||
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
export const PopoverClose = PopoverPrimitive.PopoverClose;
|
||||
|
||||
export const PopoverContent = forwardRef<
|
||||
ElementRef<typeof PopoverPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const DashboardLayout: FC = () => {
|
||||
{canViewDeployment && <LicenseBanner />}
|
||||
<AnnouncementBanners />
|
||||
|
||||
<div className="flex flex-col h-screen justify-between">
|
||||
<div className="flex flex-col min-h-screen justify-between">
|
||||
<Navbar />
|
||||
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { css, type Interpolation, type Theme } from "@emotion/react";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { linkToAuditing } from "modules/navigation";
|
||||
import type { FC } from "react";
|
||||
import { NavLink } from "react-router";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface DeploymentDropdownProps {
|
||||
canViewDeployment: boolean;
|
||||
@@ -41,18 +39,15 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="lg">
|
||||
Admin settings
|
||||
<ChevronDownIcon className="text-content-primary !size-icon-xs" />
|
||||
<ChevronDownIcon className="text-content-primary !size-icon-sm" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="bg-surface-secondary border-surface-quaternary w-[180px] min-w-auto"
|
||||
>
|
||||
<DropdownMenuContent align="end" className="w-[180px] min-w-auto">
|
||||
<DeploymentDropdownContent
|
||||
canViewDeployment={canViewDeployment}
|
||||
canViewOrganizations={canViewOrganizations}
|
||||
@@ -61,8 +56,8 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
|
||||
canViewHealth={canViewHealth}
|
||||
canViewAIBridge={canViewAIBridge}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -77,79 +72,35 @@ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
|
||||
return (
|
||||
<nav>
|
||||
{canViewDeployment && (
|
||||
<PopoverClose asChild>
|
||||
<MenuItem component={NavLink} to="/deployment" css={styles.menuItem}>
|
||||
Deployment
|
||||
</MenuItem>
|
||||
</PopoverClose>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/deployment">Deployment</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canViewOrganizations && (
|
||||
<PopoverClose asChild>
|
||||
<MenuItem
|
||||
component={NavLink}
|
||||
to="/organizations"
|
||||
css={styles.menuItem}
|
||||
>
|
||||
Organizations
|
||||
</MenuItem>
|
||||
</PopoverClose>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/organizations">Organizations</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canViewAuditLog && (
|
||||
<PopoverClose asChild>
|
||||
<MenuItem
|
||||
component={NavLink}
|
||||
to={linkToAuditing}
|
||||
css={styles.menuItem}
|
||||
>
|
||||
Audit Logs
|
||||
</MenuItem>
|
||||
</PopoverClose>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={linkToAuditing}>Audit Logs</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canViewConnectionLog && (
|
||||
<PopoverClose asChild>
|
||||
<MenuItem
|
||||
component={NavLink}
|
||||
to="/connectionlog"
|
||||
css={styles.menuItem}
|
||||
>
|
||||
Connection Logs
|
||||
</MenuItem>
|
||||
</PopoverClose>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/connectionlog">Connection Logs</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canViewAIBridge && (
|
||||
<PopoverClose asChild>
|
||||
<MenuItem component={NavLink} to="/aibridge" css={styles.menuItem}>
|
||||
AI Bridge Logs
|
||||
</MenuItem>
|
||||
</PopoverClose>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/aibridge">AI Bridge Logs</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canViewHealth && (
|
||||
<PopoverClose asChild>
|
||||
<MenuItem component={NavLink} to="/health" css={styles.menuItem}>
|
||||
Healthcheck
|
||||
</MenuItem>
|
||||
</PopoverClose>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/health">Healthcheck</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
menuItem: (theme) => css`
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.palette.action.hover};
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
`,
|
||||
menuItemIcon: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -61,7 +61,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
||||
const webPush = useWebpushNotifications();
|
||||
|
||||
return (
|
||||
<div className="border-0 border-b border-solid h-[72px] min-h-[72px] flex items-center leading-none px-6">
|
||||
<div className="sticky top-0 bg-surface-primary z-40 border-0 border-b border-solid h-[72px] min-h-[72px] flex items-center leading-none px-6">
|
||||
<NavLink to="/workspaces">
|
||||
{logo_url ? (
|
||||
<ExternalImage className="h-7" src={logo_url} alt="Custom Logo" />
|
||||
|
||||
@@ -96,7 +96,7 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
|
||||
"Select Proxy"
|
||||
)}
|
||||
|
||||
<ChevronDownIcon className="text-content-primary !size-icon-lg" />
|
||||
<ChevronDownIcon className="text-content-primary !size-icon-sm" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import type { FC } from "react";
|
||||
import { UserDropdownContent } from "./UserDropdownContent";
|
||||
|
||||
@@ -22,28 +22,24 @@ export const UserDropdown: FC<UserDropdownProps> = ({
|
||||
onSignOut,
|
||||
}) => {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-transparent border-0 cursor-pointer p-0"
|
||||
>
|
||||
<Avatar fallback={user.username} src={user.avatar_url} size="lg" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="min-w-auto w-[260px] bg-surface-secondary border-surface-quaternary"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuContent align="end" className="min-w-auto w-[260px]">
|
||||
<UserDropdownContent
|
||||
user={user}
|
||||
buildInfo={buildInfo}
|
||||
supportLinks={supportLinks}
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import { MockUserOwner } from "testHelpers/entities";
|
||||
import { render, waitForLoaderToBeRemoved } from "testHelpers/renderHelpers";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { Popover } from "components/Popover/Popover";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { Language, UserDropdownContent } from "./UserDropdownContent";
|
||||
|
||||
const renderUserDropdownContent = (props: { onSignOut: () => void }) => {
|
||||
return render(
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<UserDropdownContent
|
||||
user={MockUserOwner}
|
||||
onSignOut={props.onSignOut}
|
||||
supportLinks={[]}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("UserDropdownContent", () => {
|
||||
it("has the correct link for the account item", async () => {
|
||||
render(
|
||||
<Popover>
|
||||
<UserDropdownContent
|
||||
user={MockUserOwner}
|
||||
onSignOut={vi.fn()}
|
||||
supportLinks={[]}
|
||||
/>
|
||||
</Popover>,
|
||||
);
|
||||
renderUserDropdownContent({ onSignOut: vi.fn() });
|
||||
await waitForLoaderToBeRemoved();
|
||||
|
||||
const link = screen.getByText(Language.accountLabel).closest("a");
|
||||
@@ -27,15 +38,7 @@ describe("UserDropdownContent", () => {
|
||||
|
||||
it("calls the onSignOut function", async () => {
|
||||
const onSignOut = vi.fn();
|
||||
render(
|
||||
<Popover>
|
||||
<UserDropdownContent
|
||||
user={MockUserOwner}
|
||||
onSignOut={onSignOut}
|
||||
supportLinks={[]}
|
||||
/>
|
||||
</Popover>,
|
||||
);
|
||||
renderUserDropdownContent({ onSignOut });
|
||||
await waitForLoaderToBeRemoved();
|
||||
screen.getByText(Language.signOutLabel).click();
|
||||
expect(onSignOut).toBeCalledTimes(1);
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import {
|
||||
type CSSObject,
|
||||
css,
|
||||
type Interpolation,
|
||||
type Theme,
|
||||
} from "@emotion/react";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { PopoverClose } from "@radix-ui/react-popover";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { CopyButton } from "components/CopyButton/CopyButton";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import {
|
||||
CheckIcon,
|
||||
CircleUserIcon,
|
||||
CopyIcon,
|
||||
LogOutIcon,
|
||||
MonitorDownIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
@@ -44,153 +40,94 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
|
||||
supportLinks,
|
||||
onSignOut,
|
||||
}) => {
|
||||
const { showCopiedSuccess, copyToClipboard } = useClipboard();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Stack css={styles.info} spacing={0}>
|
||||
<span css={styles.userName}>{user.username}</span>
|
||||
<span css={styles.userEmail}>{user.email}</span>
|
||||
</Stack>
|
||||
|
||||
<Divider css={{ marginBottom: 8 }} />
|
||||
|
||||
<Link to="/install" css={styles.link}>
|
||||
<PopoverClose asChild>
|
||||
<MenuItem css={styles.menuItem}>
|
||||
<MonitorDownIcon className="size-5 text-content-secondary" />
|
||||
<span css={styles.menuItemText}>Install CLI</span>
|
||||
</MenuItem>
|
||||
</PopoverClose>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/account" css={styles.link}>
|
||||
<PopoverClose asChild>
|
||||
<MenuItem css={styles.menuItem}>
|
||||
<CircleUserIcon className="size-5 text-content-secondary" />
|
||||
<span css={styles.menuItemText}>{Language.accountLabel}</span>
|
||||
</MenuItem>
|
||||
</PopoverClose>
|
||||
</Link>
|
||||
|
||||
<MenuItem css={styles.menuItem} onClick={onSignOut}>
|
||||
<LogOutIcon className="size-5 text-content-secondary" />
|
||||
<span css={styles.menuItemText}>{Language.signOutLabel}</span>
|
||||
</MenuItem>
|
||||
|
||||
{supportLinks && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-3 [&_img]:w-full [&_img]:h-full"
|
||||
asChild
|
||||
>
|
||||
<Link to="/settings/account">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white">{user.username}</span>
|
||||
<span className="text-xs font-semibold">{user.email}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/install">
|
||||
<MonitorDownIcon />
|
||||
<span>Install CLI</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings/account">
|
||||
<CircleUserIcon />
|
||||
<span>Account</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onSignOut}>
|
||||
<LogOutIcon />
|
||||
<span>Sign Out</span>
|
||||
</DropdownMenuItem>
|
||||
{supportLinks && supportLinks.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<DropdownMenuSeparator />
|
||||
{supportLinks.map((link) => (
|
||||
<a
|
||||
href={link.target}
|
||||
key={link.name}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
css={styles.link}
|
||||
>
|
||||
<PopoverClose asChild>
|
||||
<MenuItem css={styles.menuItem}>
|
||||
{link.icon && (
|
||||
<SupportIcon
|
||||
icon={link.icon}
|
||||
className="size-5 text-content-secondary"
|
||||
/>
|
||||
)}
|
||||
<span css={styles.menuItemText}>{link.name}</span>
|
||||
</MenuItem>
|
||||
</PopoverClose>
|
||||
</a>
|
||||
<DropdownMenuItem key={link.name} asChild>
|
||||
<a href={link.target} target="_blank" rel="noreferrer">
|
||||
{link.icon && <SupportIcon icon={link.icon} />}
|
||||
<span>{link.name}</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider css={{ marginBottom: "0 !important" }} />
|
||||
|
||||
<Stack css={styles.info} spacing={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuSeparator />
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem className="text-xs" asChild>
|
||||
<a
|
||||
css={[styles.footerText, styles.buildInfo]}
|
||||
href={buildInfo?.external_url}
|
||||
className="flex items-center gap-2"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{buildInfo?.version} <SquareArrowOutUpRightIcon />
|
||||
<span className="flex-1">{buildInfo?.version}</span>
|
||||
<SquareArrowOutUpRightIcon className="!size-icon-xs" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Browse the source code</TooltipContent>
|
||||
</Tooltip>
|
||||
{buildInfo?.deployment_id && (
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="text-xs"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
copyToClipboard(buildInfo.deployment_id);
|
||||
}}
|
||||
>
|
||||
<span className="truncate flex-1">{buildInfo.deployment_id}</span>
|
||||
{showCopiedSuccess ? (
|
||||
<CheckIcon className="!size-icon-xs ml-auto" />
|
||||
) : (
|
||||
<CopyIcon className="!size-icon-xs ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Browse the source code</TooltipContent>
|
||||
<TooltipContent side="bottom">
|
||||
{showCopiedSuccess ? "Copied!" : "Copy deployment ID"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{buildInfo?.deployment_id && (
|
||||
<div className="flex items-center text-xs">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{buildInfo.deployment_id}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Deployment Identifier
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
text={buildInfo.deployment_id}
|
||||
label="Copy deployment ID"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div css={styles.footerText}>{Language.copyrightText}</div>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuItem className="text-xs" disabled>
|
||||
<span>{Language.copyrightText}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
info: (theme) => [
|
||||
theme.typography.body2 as CSSObject,
|
||||
{
|
||||
padding: 20,
|
||||
},
|
||||
],
|
||||
userName: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
userEmail: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
width: "100%",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
}),
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
},
|
||||
menuItem: (theme) => css`
|
||||
gap: 20px;
|
||||
padding: 8px 20px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.palette.action.hover};
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
`,
|
||||
menuItemText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
footerText: (theme) => css`
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
color: ${theme.palette.text.secondary};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
& svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
`,
|
||||
buildInfo: (theme) => ({
|
||||
color: theme.palette.text.primary,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import { getPreferredProxy } from "contexts/ProxyContext";
|
||||
import { screen, spyOn, userEvent, within } from "storybook/test";
|
||||
import { spyOn, userEvent, within } from "storybook/test";
|
||||
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
|
||||
|
||||
const meta: Meta<typeof AgentDevcontainerCard> = {
|
||||
@@ -185,37 +185,6 @@ 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,7 +42,6 @@ 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";
|
||||
@@ -163,8 +162,6 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
|
||||
const showSubAgentAppsPlaceholders =
|
||||
devcontainer.status === "starting" || subAgent?.status === "connecting";
|
||||
|
||||
const hasPrecreatedSubagent = isTerraformDefined(devcontainer);
|
||||
|
||||
const handleRebuildDevcontainer = () => {
|
||||
rebuildDevcontainerMutation.mutate();
|
||||
};
|
||||
@@ -235,31 +232,16 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{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>
|
||||
)}
|
||||
<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,13 +33,9 @@ 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 = 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.`;
|
||||
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.`;
|
||||
|
||||
return (
|
||||
<HelpTooltip>
|
||||
@@ -49,24 +45,22 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Stack spacing={1}>
|
||||
<div>
|
||||
<HelpTooltipTitle>{title}</HelpTooltipTitle>
|
||||
<HelpTooltipText>{text}</HelpTooltipText>
|
||||
</div>
|
||||
|
||||
{!hasPrecreatedSubagent && (
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipAction
|
||||
icon={RotateCcwIcon}
|
||||
onClick={onUpdate}
|
||||
ariaLabel="Rebuild Dev Container"
|
||||
>
|
||||
Rebuild Dev Container
|
||||
</HelpTooltipAction>
|
||||
</HelpTooltipLinksGroup>
|
||||
)}
|
||||
</div>
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipAction
|
||||
icon={RotateCcwIcon}
|
||||
onClick={onUpdate}
|
||||
ariaLabel="Rebuild Dev Container"
|
||||
>
|
||||
Rebuild Dev Container
|
||||
</HelpTooltipAction>
|
||||
</HelpTooltipLinksGroup>
|
||||
</Stack>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user