Compare commits

...

9 Commits

Author SHA1 Message Date
Garrett Delfosse 4b95b8b4f9 fix(coderd): add organization_name label to insights Prometheus metrics (cherry-pick #22296) (#22566)
Backport of #22296 to release/2.31.
2026-03-03 14:15:20 -05:00
Ehab Younes 3a061ccb21 refactor(site): use dedicated task pause/resume API endpoints (#22303) (cherry-pick/v2.31) (#22326)
Switch from workspace stop/start operations to the dedicated tasks pause
and resume endpoints for cleaner semantics.

(cherry picked from commit bf639d0016)

<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->
2026-03-03 13:14:19 -06:00
Ehab Younes 22c2da53e9 fix: register task pause/resume routes under /api/v2 (#22544) (#22550)
The pause/resume endpoints were only registered under /api/experimental
but the frontend and Go SDK were calling /api/v2, resulting in 404s.
Register the routes in the v2 group, update the SDK client paths, and
fix swagger annotations (Accept → Produce) since these POST endpoints
have no request body.

(cherry picked from commit 9d2aed88c4)
2026-03-03 13:13:48 -06:00
Kayla はな ccb529e98a fix: disable sharing ui when sharing is unavailable (#22390) (#22561) 2026-03-03 11:07:10 -07:00
Rowan Smith 107fd97a61 fix: avoid derp-related panic during wsproxy registration (backport release/2.31) (#22526)
Backport of #22322.

- Cherry-picked 7f03bd7.

Co-authored-by: Dean Sheather <dean@deansheather.com>
2026-03-03 13:46:42 +05:00
Jakub Domeracki 955637a79d fix(codersdk): use header auth for non-browser websocket dials (#22461) (cherry-pick/v2.31) (#22508)
Cherry-pick of #22461 to `release/2.31`.

Applies the non-browser websocket auth principle from #22226 to
remaining
`codersdk` websocket callsites, replacing cookie-jar session auth with
header-token auth. Fixes `401` failures on deployments with
`--host-prefix-cookie` enabled.

Closes #22461 (cherry-pick)

---------

Co-authored-by: ethan <ethanndickson@gmail.com>
2026-03-02 20:40:43 +01:00
Cian Johnston 85f1d70c4f ci: add temporary deploy override (#22378) (#22475)
Temporary override for deploying `main` to `dev.coder.com`.

(cherry picked from commit 67da4e8b56)
2026-03-02 13:58:06 +00:00
Cian Johnston e9e438b06e fix(stringutil): operate on runes instead of bytes in Truncate (#22388) (#22469)
Fixes https://github.com/coder/coder/issues/22375

Updates `stringutil.Truncate` to properly handle multi-byte UTF-8
characters.
Adds tests for multi-byte truncation with word boundary.

Created by Mux using Opus 4.6

(cherry picked from commit 0cfa03718e)
2026-03-02 11:19:16 +00:00
Steven Masley c339aa99ee chore: use header auth over cookies for agents (#22226) (cherry-pick/v2.31) (#22313)
All non-browser connections should not use cookies

(cherry picked from commit 3353e687e7)
2026-02-26 11:11:00 -06:00
54 changed files with 985 additions and 333 deletions
+2 -2
View File
@@ -1248,7 +1248,7 @@ func (api *API) postWorkspaceAgentTaskLogSnapshot(rw http.ResponseWriter, r *htt
// @Summary Pause task
// @ID pause-task
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Tasks
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
// @Param task path string true "Task ID" format(uuid)
@@ -1325,7 +1325,7 @@ func (api *API) pauseTask(rw http.ResponseWriter, r *http.Request) {
// @Summary Resume task
// @ID resume-task
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Tasks
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
// @Param task path string true "Task ID" format(uuid)
+2 -2
View File
@@ -5894,7 +5894,7 @@ const docTemplate = `{
"CoderSessionToken": []
}
],
"consumes": [
"produces": [
"application/json"
],
"tags": [
@@ -5936,7 +5936,7 @@ const docTemplate = `{
"CoderSessionToken": []
}
],
"consumes": [
"produces": [
"application/json"
],
"tags": [
+2 -2
View File
@@ -5213,7 +5213,7 @@
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Tasks"],
"summary": "Pause task",
"operationId": "pause-task",
@@ -5251,7 +5251,7 @@
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Tasks"],
"summary": "Resume task",
"operationId": "resume-task",
+5 -2
View File
@@ -329,9 +329,10 @@ func New(options *Options) *API {
panic("developer error: options.PrometheusRegistry is nil and not running a unit test")
}
if options.DeploymentValues.DisableOwnerWorkspaceExec {
if options.DeploymentValues.DisableOwnerWorkspaceExec || options.DeploymentValues.DisableWorkspaceSharing {
rbac.ReloadBuiltinRoles(&rbac.RoleOptions{
NoOwnerWorkspaceExec: true,
NoOwnerWorkspaceExec: bool(options.DeploymentValues.DisableOwnerWorkspaceExec),
NoWorkspaceSharing: bool(options.DeploymentValues.DisableWorkspaceSharing),
})
}
@@ -1734,6 +1735,8 @@ func New(options *Options) *API {
r.Patch("/input", api.taskUpdateInput)
r.Post("/send", api.taskSend)
r.Get("/logs", api.taskLogs)
r.Post("/pause", api.pauseTask)
r.Post("/resume", api.resumeTask)
})
})
})
+14 -2
View File
@@ -106,6 +106,8 @@ import (
"github.com/coder/quartz"
)
const DefaultDERPMeshKey = "test-key"
const defaultTestDaemonName = "test-daemon"
type Options struct {
@@ -512,8 +514,18 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
stunAddresses = options.DeploymentValues.DERP.Server.STUNAddresses.Value()
}
derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger.Named("derp").Leveled(slog.LevelDebug)))
derpServer.SetMeshKey("test-key")
const derpMeshKey = "test-key"
// Technically AGPL coderd servers don't set this value, but it doesn't
// change any behavior. It's useful for enterprise tests.
err = options.Database.InsertDERPMeshKey(dbauthz.AsSystemRestricted(ctx), derpMeshKey) //nolint:gocritic // test
if !database.IsUniqueViolation(err, database.UniqueSiteConfigsKeyKey) {
require.NoError(t, err, "insert DERP mesh key")
}
var derpServer *derp.Server
if options.DeploymentValues.DERP.Server.Enable.Value() {
derpServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger.Named("derp").Leveled(slog.LevelDebug)))
derpServer.SetMeshKey(derpMeshKey)
}
// match default with cli default
if options.SSHKeygenAlgorithm == "" {
@@ -19,9 +19,9 @@ import (
)
var (
templatesActiveUsersDesc = prometheus.NewDesc("coderd_insights_templates_active_users", "The number of active users of the template.", []string{"template_name"}, nil)
applicationsUsageSecondsDesc = prometheus.NewDesc("coderd_insights_applications_usage_seconds", "The application usage per template.", []string{"template_name", "application_name", "slug"}, nil)
parametersDesc = prometheus.NewDesc("coderd_insights_parameters", "The parameter usage per template.", []string{"template_name", "parameter_name", "parameter_type", "parameter_value"}, nil)
templatesActiveUsersDesc = prometheus.NewDesc("coderd_insights_templates_active_users", "The number of active users of the template.", []string{"template_name", "organization_name"}, nil)
applicationsUsageSecondsDesc = prometheus.NewDesc("coderd_insights_applications_usage_seconds", "The application usage per template.", []string{"template_name", "application_name", "slug", "organization_name"}, nil)
parametersDesc = prometheus.NewDesc("coderd_insights_parameters", "The parameter usage per template.", []string{"template_name", "parameter_name", "parameter_type", "parameter_value", "organization_name"}, nil)
)
type MetricsCollector struct {
@@ -38,7 +38,8 @@ type insightsData struct {
apps []database.GetTemplateAppInsightsByTemplateRow
params []parameterRow
templateNames map[uuid.UUID]string
templateNames map[uuid.UUID]string
organizationNames map[uuid.UUID]string // template ID → org name
}
type parameterRow struct {
@@ -137,6 +138,7 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
templateIDs := uniqueTemplateIDs(templateInsights, appInsights, paramInsights)
templateNames := make(map[uuid.UUID]string, len(templateIDs))
organizationNames := make(map[uuid.UUID]string, len(templateIDs))
if len(templateIDs) > 0 {
templates, err := mc.database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{
IDs: templateIDs,
@@ -146,6 +148,31 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
return
}
templateNames = onlyTemplateNames(templates)
// Build org name lookup so that metrics can
// distinguish templates with the same name across
// different organizations.
orgIDs := make([]uuid.UUID, 0, len(templates))
for _, t := range templates {
orgIDs = append(orgIDs, t.OrganizationID)
}
orgIDs = slice.Unique(orgIDs)
orgs, err := mc.database.GetOrganizations(ctx, database.GetOrganizationsParams{
IDs: orgIDs,
})
if err != nil {
mc.logger.Error(ctx, "unable to fetch organizations from database", slog.Error(err))
return
}
orgNameByID := make(map[uuid.UUID]string, len(orgs))
for _, o := range orgs {
orgNameByID[o.ID] = o.Name
}
organizationNames = make(map[uuid.UUID]string, len(templates))
for _, t := range templates {
organizationNames[t.ID] = orgNameByID[t.OrganizationID]
}
}
// Refresh the collector state
@@ -154,7 +181,8 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) {
apps: appInsights,
params: paramInsights,
templateNames: templateNames,
templateNames: templateNames,
organizationNames: organizationNames,
})
}
@@ -194,44 +222,46 @@ func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) {
// Custom apps
for _, appRow := range data.apps {
metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue, float64(appRow.UsageSeconds), data.templateNames[appRow.TemplateID],
appRow.DisplayName, appRow.SlugOrPort)
appRow.DisplayName, appRow.SlugOrPort, data.organizationNames[appRow.TemplateID])
}
// Built-in apps
for _, templateRow := range data.templates {
orgName := data.organizationNames[templateRow.TemplateID]
metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue,
float64(templateRow.UsageVscodeSeconds),
data.templateNames[templateRow.TemplateID],
codersdk.TemplateBuiltinAppDisplayNameVSCode,
"")
"", orgName)
metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue,
float64(templateRow.UsageJetbrainsSeconds),
data.templateNames[templateRow.TemplateID],
codersdk.TemplateBuiltinAppDisplayNameJetBrains,
"")
"", orgName)
metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue,
float64(templateRow.UsageReconnectingPtySeconds),
data.templateNames[templateRow.TemplateID],
codersdk.TemplateBuiltinAppDisplayNameWebTerminal,
"")
"", orgName)
metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue,
float64(templateRow.UsageSshSeconds),
data.templateNames[templateRow.TemplateID],
codersdk.TemplateBuiltinAppDisplayNameSSH,
"")
"", orgName)
}
// Templates
for _, templateRow := range data.templates {
metricsCh <- prometheus.MustNewConstMetric(templatesActiveUsersDesc, prometheus.GaugeValue, float64(templateRow.ActiveUsers), data.templateNames[templateRow.TemplateID])
metricsCh <- prometheus.MustNewConstMetric(templatesActiveUsersDesc, prometheus.GaugeValue, float64(templateRow.ActiveUsers), data.templateNames[templateRow.TemplateID], data.organizationNames[templateRow.TemplateID])
}
// Parameters
for _, parameterRow := range data.params {
metricsCh <- prometheus.MustNewConstMetric(parametersDesc, prometheus.GaugeValue, float64(parameterRow.count), data.templateNames[parameterRow.templateID], parameterRow.name, parameterRow.aType, parameterRow.value)
metricsCh <- prometheus.MustNewConstMetric(parametersDesc, prometheus.GaugeValue, float64(parameterRow.count), data.templateNames[parameterRow.templateID], parameterRow.name, parameterRow.aType, parameterRow.value, data.organizationNames[parameterRow.templateID])
}
}
@@ -1,13 +1,13 @@
{
"coderd_insights_applications_usage_seconds[application_name=JetBrains,slug=,template_name=golden-template]": 60,
"coderd_insights_applications_usage_seconds[application_name=Visual Studio Code,slug=,template_name=golden-template]": 60,
"coderd_insights_applications_usage_seconds[application_name=Web Terminal,slug=,template_name=golden-template]": 0,
"coderd_insights_applications_usage_seconds[application_name=SSH,slug=,template_name=golden-template]": 60,
"coderd_insights_applications_usage_seconds[application_name=Golden Slug,slug=golden-slug,template_name=golden-template]": 180,
"coderd_insights_parameters[parameter_name=first_parameter,parameter_type=string,parameter_value=Foobar,template_name=golden-template]": 1,
"coderd_insights_parameters[parameter_name=first_parameter,parameter_type=string,parameter_value=Baz,template_name=golden-template]": 1,
"coderd_insights_parameters[parameter_name=second_parameter,parameter_type=bool,parameter_value=true,template_name=golden-template]": 2,
"coderd_insights_parameters[parameter_name=third_parameter,parameter_type=number,parameter_value=789,template_name=golden-template]": 1,
"coderd_insights_parameters[parameter_name=third_parameter,parameter_type=number,parameter_value=999,template_name=golden-template]": 1,
"coderd_insights_templates_active_users[template_name=golden-template]": 1
"coderd_insights_applications_usage_seconds[application_name=JetBrains,organization_name=coder,slug=,template_name=golden-template]": 60,
"coderd_insights_applications_usage_seconds[application_name=Visual Studio Code,organization_name=coder,slug=,template_name=golden-template]": 60,
"coderd_insights_applications_usage_seconds[application_name=Web Terminal,organization_name=coder,slug=,template_name=golden-template]": 0,
"coderd_insights_applications_usage_seconds[application_name=SSH,organization_name=coder,slug=,template_name=golden-template]": 60,
"coderd_insights_applications_usage_seconds[application_name=Golden Slug,organization_name=coder,slug=golden-slug,template_name=golden-template]": 180,
"coderd_insights_parameters[organization_name=coder,parameter_name=first_parameter,parameter_type=string,parameter_value=Foobar,template_name=golden-template]": 1,
"coderd_insights_parameters[organization_name=coder,parameter_name=first_parameter,parameter_type=string,parameter_value=Baz,template_name=golden-template]": 1,
"coderd_insights_parameters[organization_name=coder,parameter_name=second_parameter,parameter_type=bool,parameter_value=true,template_name=golden-template]": 2,
"coderd_insights_parameters[organization_name=coder,parameter_name=third_parameter,parameter_type=number,parameter_value=789,template_name=golden-template]": 1,
"coderd_insights_parameters[organization_name=coder,parameter_name=third_parameter,parameter_type=number,parameter_value=999,template_name=golden-template]": 1,
"coderd_insights_templates_active_users[organization_name=coder,template_name=golden-template]": 1
}
+38 -18
View File
@@ -244,6 +244,7 @@ func SystemRoleName(name string) bool {
type RoleOptions struct {
NoOwnerWorkspaceExec bool
NoWorkspaceSharing bool
}
// ReservedRoleName exists because the database should only allow unique role
@@ -267,12 +268,23 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
opts = &RoleOptions{}
}
denyPermissions := []Permission{}
if opts.NoWorkspaceSharing {
denyPermissions = append(denyPermissions, Permission{
Negate: true,
ResourceType: ResourceWorkspace.Type,
Action: policy.ActionShare,
})
}
ownerWorkspaceActions := ResourceWorkspace.AvailableActions()
if opts.NoOwnerWorkspaceExec {
// Remove ssh and application connect from the owner role. This
// prevents owners from have exec access to all workspaces.
ownerWorkspaceActions = slice.Omit(ownerWorkspaceActions,
policy.ActionApplicationConnect, policy.ActionSSH)
ownerWorkspaceActions = slice.Omit(
ownerWorkspaceActions,
policy.ActionApplicationConnect, policy.ActionSSH,
)
}
// Static roles that never change should be allocated in a closure.
@@ -295,7 +307,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
})...),
})...,
),
User: []Permission{},
ByOrgID: map[string]OrgPermissions{},
}.withCachedRegoValue()
@@ -303,13 +316,17 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
memberRole := Role{
Identifier: RoleMember(),
DisplayName: "Member",
Site: Permissions(map[string][]policy.Action{
ResourceAssignRole.Type: {policy.ActionRead},
// All users can see OAuth2 provider applications.
ResourceOauth2App.Type: {policy.ActionRead},
ResourceWorkspaceProxy.Type: {policy.ActionRead},
}),
User: append(allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceOrganizationMember, ResourceBoundaryUsage),
Site: append(
Permissions(map[string][]policy.Action{
ResourceAssignRole.Type: {policy.ActionRead},
// All users can see OAuth2 provider applications.
ResourceOauth2App.Type: {policy.ActionRead},
ResourceWorkspaceProxy.Type: {policy.ActionRead},
}),
denyPermissions...,
),
User: append(
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceOrganizationMember, ResourceBoundaryUsage),
Permissions(map[string][]policy.Action{
// Users cannot do create/update/delete on themselves, but they
// can read their own details.
@@ -433,14 +450,17 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ByOrgID: map[string]OrgPermissions{
// Org admins should not have workspace exec perms.
organizationID.String(): {
Org: append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage), Permissions(map[string][]policy.Action{
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH),
// PrebuiltWorkspaces are a subset of Workspaces.
// Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
})...),
Org: append(
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage),
Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH),
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
// PrebuiltWorkspaces are a subset of Workspaces.
// Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
})...,
),
Member: []Permission{},
},
},
+2 -2
View File
@@ -177,7 +177,7 @@ func generateFromPrompt(prompt string) (TaskName, error) {
// Ensure display name is never empty
displayName = strings.ReplaceAll(name, "-", " ")
}
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
displayName = strutil.Capitalize(displayName)
return TaskName{
Name: taskName,
@@ -269,7 +269,7 @@ func generateFromAnthropic(ctx context.Context, prompt string, apiKey string, mo
// Ensure display name is never empty
displayName = strings.ReplaceAll(taskNameResponse.Name, "-", " ")
}
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
displayName = strutil.Capitalize(displayName)
return TaskName{
Name: name,
+13
View File
@@ -49,6 +49,19 @@ func TestGenerate(t *testing.T) {
require.NotEmpty(t, taskName.DisplayName)
})
t.Run("FromPromptMultiByte", func(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
ctx := testutil.Context(t, testutil.WaitShort)
taskName := taskname.Generate(ctx, testutil.Logger(t), "über cool feature")
require.NoError(t, codersdk.NameValid(taskName.Name))
require.True(t, len(taskName.DisplayName) > 0)
// The display name must start with "Ü", not corrupted bytes.
require.Equal(t, "Über cool feature", taskName.DisplayName)
})
t.Run("Fallback", func(t *testing.T) {
// Ensure no API key
t.Setenv("ANTHROPIC_API_KEY", "")
+22 -10
View File
@@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"unicode"
"unicode/utf8"
"github.com/acarl005/stripansi"
"github.com/microcosm-cc/bluemonday"
@@ -53,7 +54,7 @@ const (
TruncateWithFullWords TruncateOption = 1 << 1
)
// Truncate truncates s to n characters.
// Truncate truncates s to n runes.
// Additional behaviors can be specified using TruncateOptions.
func Truncate(s string, n int, opts ...TruncateOption) string {
var options TruncateOption
@@ -63,7 +64,8 @@ func Truncate(s string, n int, opts ...TruncateOption) string {
if n < 1 {
return ""
}
if len(s) <= n {
runes := []rune(s)
if len(runes) <= n {
return s
}
@@ -72,18 +74,18 @@ func Truncate(s string, n int, opts ...TruncateOption) string {
maxLen--
}
var sb strings.Builder
// If we need to truncate to full words, find the last word boundary before n.
if options&TruncateWithFullWords != 0 {
lastWordBoundary := strings.LastIndexFunc(s[:maxLen], unicode.IsSpace)
// Convert the rune-safe prefix to a string, then find
// the last word boundary (byte offset within that prefix).
truncated := string(runes[:maxLen])
lastWordBoundary := strings.LastIndexFunc(truncated, unicode.IsSpace)
if lastWordBoundary < 0 {
// We cannot find a word boundary. At this point, we'll truncate the string.
// It's better than nothing.
_, _ = sb.WriteString(s[:maxLen])
} else { // lastWordBoundary <= maxLen
_, _ = sb.WriteString(s[:lastWordBoundary])
_, _ = sb.WriteString(truncated)
} else {
_, _ = sb.WriteString(truncated[:lastWordBoundary])
}
} else {
_, _ = sb.WriteString(s[:maxLen])
_, _ = sb.WriteString(string(runes[:maxLen]))
}
if options&TruncateWithEllipsis != 0 {
@@ -126,3 +128,13 @@ func UISanitize(in string) string {
}
return strings.TrimSpace(b.String())
}
// Capitalize returns s with its first rune upper-cased. It is safe for
// multi-byte UTF-8 characters, unlike naive byte-slicing approaches.
func Capitalize(s string) string {
r, size := utf8.DecodeRuneInString(s)
if size == 0 {
return s
}
return string(unicode.ToUpper(r)) + s[size:]
}
+32
View File
@@ -57,6 +57,17 @@ func TestTruncate(t *testing.T) {
{"foo bar", 1, "…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
{"foo bar", 0, "", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
{"This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", 160, "This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
// Multi-byte rune handling.
{"日本語テスト", 3, "日本語", nil},
{"日本語テスト", 4, "日本語テ", nil},
{"日本語テスト", 6, "日本語テスト", nil},
{"日本語テスト", 4, "日本語…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
{"🎉🎊🎈🎁", 2, "🎉🎊", nil},
{"🎉🎊🎈🎁", 3, "🎉🎊…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
// Multi-byte with full-word truncation.
{"hello 日本語", 7, "hello…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
{"hello 日本語", 8, "hello 日…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
{"日本語 テスト", 4, "日本語", []strings.TruncateOption{strings.TruncateWithFullWords}},
} {
tName := fmt.Sprintf("%s_%d", tt.s, tt.n)
for _, opt := range tt.options {
@@ -107,3 +118,24 @@ func TestUISanitize(t *testing.T) {
})
}
}
func TestCapitalize(t *testing.T) {
t.Parallel()
tests := []struct {
input string
expected string
}{
{"", ""},
{"hello", "Hello"},
{"über", "Über"},
{"Hello", "Hello"},
{"a", "A"},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%q", tt.input), func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, strings.Capitalize(tt.input))
})
}
}
+4
View File
@@ -5572,6 +5572,10 @@ func TestWorkspaceSharingDisabled(t *testing.T) {
})
t.Run("NoAccessWhenDisabled", func(t *testing.T) {
t.Cleanup(func() {
rbac.ReloadBuiltinRoles(nil)
})
var (
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) {
+4 -19
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"sync"
"time"
@@ -321,21 +320,15 @@ func (c *Client) connectRPCVersion(ctx context.Context, version *apiversion.APIV
}
rpcURL.RawQuery = q.Encode()
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(rpcURL, []*http.Cookie{{
Name: codersdk.SessionTokenCookie,
Value: c.SDK.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.SDK.HTTPClient.Transport,
}
// nolint:bodyclose
conn, res, err := websocket.Dial(ctx, rpcURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
HTTPHeader: http.Header{
codersdk.SessionTokenHeader: []string{c.SDK.SessionToken()},
},
})
if err != nil {
if res == nil {
@@ -709,16 +702,7 @@ func (c *Client) WaitForReinit(ctx context.Context) (*ReinitializationEvent, err
return nil, xerrors.Errorf("parse url: %w", err)
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(rpcURL, []*http.Cookie{{
Name: codersdk.SessionTokenCookie,
Value: c.SDK.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.SDK.HTTPClient.Transport,
}
@@ -726,6 +710,7 @@ func (c *Client) WaitForReinit(ctx context.Context) (*ReinitializationEvent, err
if err != nil {
return nil, xerrors.Errorf("build request: %w", err)
}
req.Header[codersdk.SessionTokenHeader] = []string{c.SDK.SessionToken()}
res, err := httpClient.Do(req)
if err != nil {
+2 -3
View File
@@ -335,9 +335,8 @@ type PauseTaskResponse struct {
}
// PauseTask pauses a task by stopping its workspace.
// Experimental: uses the /api/experimental endpoint.
func (c *Client) PauseTask(ctx context.Context, user string, id uuid.UUID) (PauseTaskResponse, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/pause", user, id.String()), nil)
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/tasks/%s/%s/pause", user, id.String()), nil)
if err != nil {
return PauseTaskResponse{}, err
}
@@ -360,7 +359,7 @@ type ResumeTaskResponse struct {
}
func (c *Client) ResumeTask(ctx context.Context, user string, id uuid.UUID) (ResumeTaskResponse, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/resume", user, id.String()), nil)
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/tasks/%s/%s/resume", user, id.String()), nil)
if err != nil {
return ResumeTaskResponse{}, err
}
+6 -21
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"slices"
"strings"
"time"
@@ -239,20 +238,14 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after
if err != nil {
return nil, nil, err
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(followURL, []*http.Cookie{{
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.HTTPClient.Transport,
}
conn, res, err := websocket.Dial(ctx, followURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
HTTPClient: httpClient,
HTTPHeader: http.Header{
SessionTokenHeader: []string{c.SessionToken()},
},
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
@@ -325,16 +318,8 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisione
headers.Set(ProvisionerDaemonPSK, req.PreSharedKey)
}
if req.ProvisionerKey == "" && req.PreSharedKey == "" {
// use session token if we don't have a PSK or provisioner key.
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient.Jar = jar
// Use session token if we don't have a PSK or provisioner key.
headers.Set(SessionTokenHeader, c.SessionToken())
}
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{
+7 -22
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"time"
@@ -580,24 +579,16 @@ func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid
return nil, nil, err
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(reqURL, []*http.Cookie{{
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{
// We want `NoContextTakeover` compression to balance improving
// bandwidth cost/latency with minimal memory usage overhead.
CompressionMode: websocket.CompressionNoContextTakeover,
HTTPClient: &http.Client{
Jar: jar,
Transport: c.HTTPClient.Transport,
},
HTTPHeader: http.Header{
SessionTokenHeader: []string{c.SessionToken()},
},
})
if err != nil {
if res == nil {
@@ -687,20 +678,14 @@ func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID,
return ch, closeFunc(func() error { return nil }), nil
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(reqURL, []*http.Cookie{{
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.HTTPClient.Transport,
}
conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
HTTPClient: httpClient,
HTTPHeader: http.Header{
SessionTokenHeader: []string{c.SessionToken()},
},
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
+13 -17
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"net"
"net/http"
"net/http/cookiejar"
"net/netip"
"os"
"strconv"
@@ -363,26 +362,23 @@ func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentRe
}
serverURL.RawQuery = q.Encode()
// If we're not using a signed token, we need to set the session token as a
// cookie.
httpClient := c.client.HTTPClient
// Shallow-clone the HTTP client so we never inherit a caller-provided
// cookie jar. Non-browser websocket auth uses the Coder-Session-Token
// header or a signed-token query param — never cookies. A stale jar
// cookie would take precedence on the server (cookies are checked
// before headers) and cause spurious 401s.
wsHTTPClient := *c.client.HTTPClient
wsHTTPClient.Jar = nil
headers := http.Header{}
// If we're not using a signed token, set the session token header.
if opts.SignedToken == "" {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{
Name: codersdk.SessionTokenCookie,
Value: c.client.SessionToken(),
}})
httpClient = &http.Client{
Jar: jar,
Transport: c.client.HTTPClient.Transport,
}
headers.Set(codersdk.SessionTokenHeader, c.client.SessionToken())
}
//nolint:bodyclose
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
HTTPClient: &wsHTTPClient,
HTTPHeader: headers,
})
if err != nil {
if res == nil {
+3 -3
View File
@@ -175,9 +175,9 @@ deployment. They will always be available from the agent.
| `coderd_dbpurge_iteration_duration_seconds` | histogram | Duration of each dbpurge iteration in seconds. | `success` |
| `coderd_dbpurge_records_purged_total` | counter | Total number of records purged by type. | `record_type` |
| `coderd_experiments` | gauge | Indicates whether each experiment is enabled (1) or not (0) | `experiment` |
| `coderd_insights_applications_usage_seconds` | gauge | The application usage per template. | `application_name` `slug` `template_name` |
| `coderd_insights_parameters` | gauge | The parameter usage per template. | `parameter_name` `parameter_type` `parameter_value` `template_name` |
| `coderd_insights_templates_active_users` | gauge | The number of active users of the template. | `template_name` |
| `coderd_insights_applications_usage_seconds` | gauge | The application usage per template. | `application_name` `organization_name` `slug` `template_name` |
| `coderd_insights_parameters` | gauge | The parameter usage per template. | `organization_name` `parameter_name` `parameter_type` `parameter_value` `template_name` |
| `coderd_insights_templates_active_users` | gauge | The number of active users of the template. | `organization_name` `template_name` |
| `coderd_license_active_users` | gauge | The number of active users. | |
| `coderd_license_errors` | gauge | The number of active license errors. | |
| `coderd_license_limit_users` | gauge | The user seats limit based on the active Coder license. | |
+424 -2
View File
@@ -372,7 +372,7 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio
```shell
# Example request using curl
curl -X POST http://coder-server:8080/api/v2/tasks/{user}/{task}/pause \
-H 'Accept: */*' \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
@@ -389,6 +389,217 @@ curl -X POST http://coder-server:8080/api/v2/tasks/{user}/{task}/pause \
> 202 Response
```json
{
"workspace_build": {
"build_number": 0,
"created_at": "2019-08-24T14:15:22Z",
"daily_cost": 0,
"deadline": "2019-08-24T14:15:22Z",
"has_ai_task": true,
"has_external_agent": true,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
"initiator_name": "string",
"job": {
"available_workers": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"canceled_at": "2019-08-24T14:15:22Z",
"completed_at": "2019-08-24T14:15:22Z",
"created_at": "2019-08-24T14:15:22Z",
"error": "string",
"error_code": "REQUIRED_TEMPLATE_VARIABLES",
"file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
"input": {
"error": "string",
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
"workspace_build_id": "badaf2eb-96c5-4050-9f1d-db2d39ca5478"
},
"logs_overflowed": true,
"metadata": {
"template_display_name": "string",
"template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
"workspace_name": "string"
},
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"queue_position": 0,
"queue_size": 0,
"started_at": "2019-08-24T14:15:22Z",
"status": "pending",
"tags": {
"property1": "string",
"property2": "string"
},
"type": "template_version_import",
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b",
"worker_name": "string"
},
"matched_provisioners": {
"available": 0,
"count": 0,
"most_recently_seen": "2019-08-24T14:15:22Z"
},
"max_deadline": "2019-08-24T14:15:22Z",
"reason": "initiator",
"resources": [
{
"agents": [
{
"api_version": "string",
"apps": [
{
"command": "string",
"display_name": "string",
"external": true,
"group": "string",
"health": "disabled",
"healthcheck": {
"interval": 0,
"threshold": 0,
"url": "string"
},
"hidden": true,
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"open_in": "slim-window",
"sharing_level": "owner",
"slug": "string",
"statuses": [
{
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
"app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335",
"created_at": "2019-08-24T14:15:22Z",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"message": "string",
"needs_user_attention": true,
"state": "working",
"uri": "string",
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9"
}
],
"subdomain": true,
"subdomain_name": "string",
"tooltip": "string",
"url": "string"
}
],
"architecture": "string",
"connection_timeout_seconds": 0,
"created_at": "2019-08-24T14:15:22Z",
"directory": "string",
"disconnected_at": "2019-08-24T14:15:22Z",
"display_apps": [
"vscode"
],
"environment_variables": {
"property1": "string",
"property2": "string"
},
"expanded_directory": "string",
"first_connected_at": "2019-08-24T14:15:22Z",
"health": {
"healthy": false,
"reason": "agent has lost connection"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"instance_id": "string",
"last_connected_at": "2019-08-24T14:15:22Z",
"latency": {
"property1": {
"latency_ms": 0,
"preferred": true
},
"property2": {
"latency_ms": 0,
"preferred": true
}
},
"lifecycle_state": "created",
"log_sources": [
{
"created_at": "2019-08-24T14:15:22Z",
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"workspace_agent_id": "7ad2e618-fea7-4c1a-b70a-f501566a72f1"
}
],
"logs_length": 0,
"logs_overflowed": true,
"name": "string",
"operating_system": "string",
"parent_id": {
"uuid": "string",
"valid": true
},
"ready_at": "2019-08-24T14:15:22Z",
"resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f",
"scripts": [
{
"cron": "string",
"display_name": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"log_path": "string",
"log_source_id": "4197ab25-95cf-4b91-9c78-f7f2af5d353a",
"run_on_start": true,
"run_on_stop": true,
"script": "string",
"start_blocks_login": true,
"timeout": 0
}
],
"started_at": "2019-08-24T14:15:22Z",
"startup_script_behavior": "blocking",
"status": "connecting",
"subsystems": [
"envbox"
],
"troubleshooting_url": "string",
"updated_at": "2019-08-24T14:15:22Z",
"version": "string"
}
],
"created_at": "2019-08-24T14:15:22Z",
"daily_cost": 0,
"hide": true,
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f",
"metadata": [
{
"key": "string",
"sensitive": true,
"value": "string"
}
],
"name": "string",
"type": "string",
"workspace_transition": "start"
}
],
"status": "pending",
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
"template_version_name": "string",
"template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1",
"transition": "start",
"updated_at": "2019-08-24T14:15:22Z",
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string"
}
}
```
### Responses
| Status | Meaning | Description | Schema |
@@ -404,7 +615,7 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio
```shell
# Example request using curl
curl -X POST http://coder-server:8080/api/v2/tasks/{user}/{task}/resume \
-H 'Accept: */*' \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
@@ -421,6 +632,217 @@ curl -X POST http://coder-server:8080/api/v2/tasks/{user}/{task}/resume \
> 202 Response
```json
{
"workspace_build": {
"build_number": 0,
"created_at": "2019-08-24T14:15:22Z",
"daily_cost": 0,
"deadline": "2019-08-24T14:15:22Z",
"has_ai_task": true,
"has_external_agent": true,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
"initiator_name": "string",
"job": {
"available_workers": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"canceled_at": "2019-08-24T14:15:22Z",
"completed_at": "2019-08-24T14:15:22Z",
"created_at": "2019-08-24T14:15:22Z",
"error": "string",
"error_code": "REQUIRED_TEMPLATE_VARIABLES",
"file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
"input": {
"error": "string",
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
"workspace_build_id": "badaf2eb-96c5-4050-9f1d-db2d39ca5478"
},
"logs_overflowed": true,
"metadata": {
"template_display_name": "string",
"template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
"workspace_name": "string"
},
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"queue_position": 0,
"queue_size": 0,
"started_at": "2019-08-24T14:15:22Z",
"status": "pending",
"tags": {
"property1": "string",
"property2": "string"
},
"type": "template_version_import",
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b",
"worker_name": "string"
},
"matched_provisioners": {
"available": 0,
"count": 0,
"most_recently_seen": "2019-08-24T14:15:22Z"
},
"max_deadline": "2019-08-24T14:15:22Z",
"reason": "initiator",
"resources": [
{
"agents": [
{
"api_version": "string",
"apps": [
{
"command": "string",
"display_name": "string",
"external": true,
"group": "string",
"health": "disabled",
"healthcheck": {
"interval": 0,
"threshold": 0,
"url": "string"
},
"hidden": true,
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"open_in": "slim-window",
"sharing_level": "owner",
"slug": "string",
"statuses": [
{
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
"app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335",
"created_at": "2019-08-24T14:15:22Z",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"message": "string",
"needs_user_attention": true,
"state": "working",
"uri": "string",
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9"
}
],
"subdomain": true,
"subdomain_name": "string",
"tooltip": "string",
"url": "string"
}
],
"architecture": "string",
"connection_timeout_seconds": 0,
"created_at": "2019-08-24T14:15:22Z",
"directory": "string",
"disconnected_at": "2019-08-24T14:15:22Z",
"display_apps": [
"vscode"
],
"environment_variables": {
"property1": "string",
"property2": "string"
},
"expanded_directory": "string",
"first_connected_at": "2019-08-24T14:15:22Z",
"health": {
"healthy": false,
"reason": "agent has lost connection"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"instance_id": "string",
"last_connected_at": "2019-08-24T14:15:22Z",
"latency": {
"property1": {
"latency_ms": 0,
"preferred": true
},
"property2": {
"latency_ms": 0,
"preferred": true
}
},
"lifecycle_state": "created",
"log_sources": [
{
"created_at": "2019-08-24T14:15:22Z",
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"workspace_agent_id": "7ad2e618-fea7-4c1a-b70a-f501566a72f1"
}
],
"logs_length": 0,
"logs_overflowed": true,
"name": "string",
"operating_system": "string",
"parent_id": {
"uuid": "string",
"valid": true
},
"ready_at": "2019-08-24T14:15:22Z",
"resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f",
"scripts": [
{
"cron": "string",
"display_name": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"log_path": "string",
"log_source_id": "4197ab25-95cf-4b91-9c78-f7f2af5d353a",
"run_on_start": true,
"run_on_stop": true,
"script": "string",
"start_blocks_login": true,
"timeout": 0
}
],
"started_at": "2019-08-24T14:15:22Z",
"startup_script_behavior": "blocking",
"status": "connecting",
"subsystems": [
"envbox"
],
"troubleshooting_url": "string",
"updated_at": "2019-08-24T14:15:22Z",
"version": "string"
}
],
"created_at": "2019-08-24T14:15:22Z",
"daily_cost": 0,
"hide": true,
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f",
"metadata": [
{
"key": "string",
"sensitive": true,
"value": "string"
}
],
"name": "string",
"type": "string",
"workspace_transition": "start"
}
],
"status": "pending",
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
"template_version_name": "string",
"template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1",
"transition": "start",
"updated_at": "2019-08-24T14:15:22Z",
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string"
}
}
```
### Responses
| Status | Meaning | Description | Schema |
+36 -32
View File
@@ -39,40 +39,44 @@ func (r *RootCmd) Server(_ func()) *serpent.Command {
}
}
// Always generate a mesh key, even if the built-in DERP server is
// disabled. This mesh key is still used by workspace proxies running
// HA.
var meshKey string
err := options.Database.InTx(func(tx database.Store) error {
// This will block until the lock is acquired, and will be
// automatically released when the transaction ends.
err := tx.AcquireLock(ctx, database.LockIDEnterpriseDeploymentSetup)
if err != nil {
return xerrors.Errorf("acquire lock: %w", err)
}
meshKey, err = tx.GetDERPMeshKey(ctx)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get DERP mesh key: %w", err)
}
meshKey, err = cryptorand.String(32)
if err != nil {
return xerrors.Errorf("generate DERP mesh key: %w", err)
}
err = tx.InsertDERPMeshKey(ctx, meshKey)
if err != nil {
return xerrors.Errorf("insert DERP mesh key: %w", err)
}
return nil
}, nil)
if err != nil {
return nil, nil, err
}
if meshKey == "" {
return nil, nil, xerrors.New("mesh key is empty")
}
if options.DeploymentValues.DERP.Server.Enable {
options.DERPServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger.Named("derp")))
var meshKey string
err := options.Database.InTx(func(tx database.Store) error {
// This will block until the lock is acquired, and will be
// automatically released when the transaction ends.
err := tx.AcquireLock(ctx, database.LockIDEnterpriseDeploymentSetup)
if err != nil {
return xerrors.Errorf("acquire lock: %w", err)
}
meshKey, err = tx.GetDERPMeshKey(ctx)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get DERP mesh key: %w", err)
}
meshKey, err = cryptorand.String(32)
if err != nil {
return xerrors.Errorf("generate DERP mesh key: %w", err)
}
err = tx.InsertDERPMeshKey(ctx, meshKey)
if err != nil {
return xerrors.Errorf("insert DERP mesh key: %w", err)
}
return nil
}, nil)
if err != nil {
return nil, nil, err
}
if meshKey == "" {
return nil, nil, xerrors.New("mesh key is empty")
}
options.DERPServer.SetMeshKey(meshKey)
}
+20 -1
View File
@@ -604,6 +604,25 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
return
}
// Load the mesh key directly from the database. We don't retrieve the mesh
// key from the built-in DERP server because it may not be enabled.
//
// The mesh key is always generated at startup by an enterprise coderd
// server.
var meshKey string
if req.DerpEnabled {
var err error
meshKey, err = api.Database.GetDERPMeshKey(ctx)
if err != nil {
httpapi.InternalServerError(rw, xerrors.Errorf("get DERP mesh key: %w", err))
return
}
if meshKey == "" {
httpapi.InternalServerError(rw, xerrors.New("mesh key is empty"))
return
}
}
startingRegionID, _ := getProxyDERPStartingRegionID(api.Options.BaseDERPMap)
// #nosec G115 - Safe conversion as DERP region IDs are small integers expected to be within int32 range
regionID := int32(startingRegionID) + proxy.RegionID
@@ -710,7 +729,7 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
}
httpapi.Write(ctx, rw, http.StatusCreated, wsproxysdk.RegisterWorkspaceProxyResponse{
DERPMeshKey: api.DERPServer.MeshKey(),
DERPMeshKey: meshKey,
DERPRegionID: regionID,
DERPMap: api.AGPL.DERPMap(),
DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(),
+106 -2
View File
@@ -2,12 +2,15 @@ package coderd_test
import (
"database/sql"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"runtime"
"testing"
"time"
@@ -16,6 +19,7 @@ import (
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agenttest"
@@ -34,6 +38,7 @@ import (
"github.com/coder/coder/v2/enterprise/wsproxy/wsproxysdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestRegions(t *testing.T) {
@@ -278,10 +283,11 @@ func TestWorkspaceProxyCRUD(t *testing.T) {
func TestProxyRegisterDeregister(t *testing.T) {
t.Parallel()
setup := func(t *testing.T) (*codersdk.Client, database.Store) {
setupWithDeploymentValues := func(t *testing.T, dv *codersdk.DeploymentValues) (*codersdk.Client, database.Store) {
db, pubsub := dbtestutil.NewDB(t)
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
Database: db,
Pubsub: pubsub,
IncludeProvisionerDaemon: true,
@@ -297,6 +303,11 @@ func TestProxyRegisterDeregister(t *testing.T) {
return client, db
}
setup := func(t *testing.T) (*codersdk.Client, database.Store) {
dv := coderdtest.DeploymentValues(t)
return setupWithDeploymentValues(t, dv)
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
@@ -363,7 +374,7 @@ func TestProxyRegisterDeregister(t *testing.T) {
req = wsproxysdk.RegisterWorkspaceProxyRequest{
AccessURL: "https://cool.proxy.coder.test",
WildcardHostname: "*.cool.proxy.coder.test",
DerpEnabled: false,
DerpEnabled: true,
ReplicaID: req.ReplicaID,
ReplicaHostname: "venus",
ReplicaError: "error",
@@ -608,6 +619,99 @@ func TestProxyRegisterDeregister(t *testing.T) {
require.True(t, ok, "expected to register replica %d", i)
}
})
t.Run("RegisterWithDisabledBuiltInDERP/DerpEnabled", func(t *testing.T) {
t.Parallel()
// Create a DERP map file. Currently, Coder refuses to start if there
// are zero DERP regions.
// TODO: ideally coder can start without any DERP servers if the
// customer is going to be using DERPs via proxies. We could make it
// a configuration value to allow an empty DERP map on startup or
// something.
tmpDir := t.TempDir()
derpPath := filepath.Join(tmpDir, "derp.json")
content, err := json.Marshal(&tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
Nodes: []*tailcfg.DERPNode{{}},
},
},
})
require.NoError(t, err)
require.NoError(t, os.WriteFile(derpPath, content, 0o600))
dv := coderdtest.DeploymentValues(t)
dv.DERP.Server.Enable = false // disable built-in DERP server
dv.DERP.Config.Path = serpent.String(derpPath)
client, _ := setupWithDeploymentValues(t, dv)
ctx := testutil.Context(t, testutil.WaitLong)
createRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
Name: "proxy",
})
require.NoError(t, err)
proxyClient := wsproxysdk.New(client.URL, createRes.ProxyToken)
registerRes, err := proxyClient.RegisterWorkspaceProxy(ctx, wsproxysdk.RegisterWorkspaceProxyRequest{
AccessURL: "https://proxy.coder.test",
WildcardHostname: "*.proxy.coder.test",
DerpEnabled: true,
ReplicaID: uuid.New(),
ReplicaHostname: "venus",
ReplicaError: "",
ReplicaRelayAddress: "http://127.0.0.1:8080",
Version: buildinfo.Version(),
})
require.NoError(t, err)
// Should still be able to retrieve the DERP mesh key from the database,
// even though the built-in DERP server is disabled.
require.Equal(t, registerRes.DERPMeshKey, coderdtest.DefaultDERPMeshKey)
})
t.Run("RegisterWithDisabledBuiltInDERP/DerpEnabled", func(t *testing.T) {
t.Parallel()
// Same as above.
tmpDir := t.TempDir()
derpPath := filepath.Join(tmpDir, "derp.json")
content, err := json.Marshal(&tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
Nodes: []*tailcfg.DERPNode{{}},
},
},
})
require.NoError(t, err)
require.NoError(t, os.WriteFile(derpPath, content, 0o600))
dv := coderdtest.DeploymentValues(t)
dv.DERP.Server.Enable = false // disable built-in DERP server
dv.DERP.Config.Path = serpent.String(derpPath)
client, _ := setupWithDeploymentValues(t, dv)
ctx := testutil.Context(t, testutil.WaitLong)
createRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
Name: "proxy",
})
require.NoError(t, err)
proxyClient := wsproxysdk.New(client.URL, createRes.ProxyToken)
registerRes, err := proxyClient.RegisterWorkspaceProxy(ctx, wsproxysdk.RegisterWorkspaceProxyRequest{
AccessURL: "https://proxy.coder.test",
WildcardHostname: "*.proxy.coder.test",
DerpEnabled: false,
ReplicaID: uuid.New(),
ReplicaHostname: "venus",
ReplicaError: "",
ReplicaRelayAddress: "http://127.0.0.1:8080",
Version: buildinfo.Version(),
})
require.NoError(t, err)
// The server shouldn't bother querying or returning the DERP mesh key
// if the proxy's DERP server is disabled.
require.Empty(t, registerRes.DERPMeshKey)
})
}
func TestIssueSignedAppToken(t *testing.T) {
+3 -3
View File
@@ -159,13 +159,13 @@ coderd_dbpurge_records_purged_total{record_type=""} 0
coderd_experiments{experiment=""} 0
# HELP coderd_insights_applications_usage_seconds The application usage per template.
# TYPE coderd_insights_applications_usage_seconds gauge
coderd_insights_applications_usage_seconds{template_name="",application_name="",slug=""} 0
coderd_insights_applications_usage_seconds{template_name="",application_name="",slug="",organization_name=""} 0
# HELP coderd_insights_parameters The parameter usage per template.
# TYPE coderd_insights_parameters gauge
coderd_insights_parameters{template_name="",parameter_name="",parameter_type="",parameter_value=""} 0
coderd_insights_parameters{template_name="",parameter_name="",parameter_type="",parameter_value="",organization_name=""} 0
# HELP coderd_insights_templates_active_users The number of active users of the template.
# TYPE coderd_insights_templates_active_users gauge
coderd_insights_templates_active_users{template_name=""} 0
coderd_insights_templates_active_users{template_name="",organization_name=""} 0
# HELP coderd_license_active_users The number of active users.
# TYPE coderd_license_active_users gauge
coderd_license_active_users 0
+15
View File
@@ -16,6 +16,21 @@ deploy_branch=main
# Determine the current branch name and check that it is one of the supported
# branch names.
branch_name=$(git branch --show-current)
# --- BEGIN TEMPORARY SHORT-CIRCUIT ---
# Forces deployment of main. Remove after 2026-03-04T12:00Z.
if [[ "$branch_name" == "main" ]]; then
log "TEMPORARY SHORT-CIRCUIT: deploying main"
log "VERDICT: DEPLOY"
echo "DEPLOY"
exit 0
else
log "VERDICT: DO NOT DEPLOY"
echo "NOOP"
exit 0
fi
# --- END TEMPORARY SHORT-CIRCUIT ---
if [[ "$branch_name" != "main" && ! "$branch_name" =~ ^release/[0-9]+\.[0-9]+$ ]]; then
error "Current branch '$branch_name' is not a supported branch name for dogfood, must be 'main' or 'release/x.y'"
fi
+1 -1
View File
@@ -248,7 +248,7 @@ export const patchRoleSyncSettings = (
};
};
const getWorkspaceSharingSettingsKey = (organization: string) => [
export const getWorkspaceSharingSettingsKey = (organization: string) => [
"organization",
organization,
"workspaceSharingSettings",
+2 -13
View File
@@ -17,10 +17,7 @@ export const taskLogs = (user: string, taskId: string) => ({
export const pauseTask = (task: Task, queryClient: QueryClient) => {
return {
mutationFn: async () => {
if (!task.workspace_id) {
throw new Error("Task has no workspace");
}
return API.stopWorkspace(task.workspace_id);
return API.pauseTask(task.owner_name, task.id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
@@ -31,15 +28,7 @@ export const pauseTask = (task: Task, queryClient: QueryClient) => {
export const resumeTask = (task: Task, queryClient: QueryClient) => {
return {
mutationFn: async () => {
if (!task.workspace_id) {
throw new Error("Task has no workspace");
}
return API.startWorkspace(
task.workspace_id,
task.template_version_id,
undefined,
undefined,
);
return API.resumeTask(task.owner_name, task.id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
+1 -1
View File
@@ -479,7 +479,7 @@ export const workspacePermissions = (workspace?: Workspace) => {
checks: workspace ? workspaceChecks(workspace) : {},
}),
queryKey: ["workspaces", workspace?.id, "permissions"],
enabled: !!workspace,
enabled: Boolean(workspace),
staleTime: Number.POSITIVE_INFINITY,
};
};
@@ -1,3 +1,4 @@
import { workspaceSharingSettings } from "api/queries/organizations";
import type {
Group,
WorkspaceACL,
@@ -37,6 +38,7 @@ import { TableLoader } from "components/TableLoader/TableLoader";
import { EllipsisVertical, UserPlusIcon } from "lucide-react";
import { getGroupSubtitle } from "modules/groups";
import type { FC, ReactNode } from "react";
import { useQuery } from "react-query";
interface RoleSelectProps {
value: WorkspaceRole;
@@ -139,6 +141,7 @@ export const RoleSelectField: FC<RoleSelectFieldProps> = ({
};
interface WorkspaceSharingFormProps {
organizationId: string;
workspaceACL: WorkspaceACL | undefined;
canUpdatePermissions: boolean;
isTaskWorkspace: boolean;
@@ -155,6 +158,7 @@ interface WorkspaceSharingFormProps {
}
export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
organizationId,
workspaceACL,
canUpdatePermissions,
isTaskWorkspace,
@@ -169,6 +173,46 @@ export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
isCompact,
showRestartWarning,
}) => {
const sharingSettingsQuery = useQuery(
workspaceSharingSettings(organizationId),
);
if (sharingSettingsQuery.isLoading) {
return (
<TableBody>
<TableLoader />
</TableBody>
);
}
if (!sharingSettingsQuery.data) {
return (
<TableBody>
<TableRow>
<TableCell colSpan={999}>
<ErrorAlert error={sharingSettingsQuery.error} />
</TableCell>
</TableRow>
</TableBody>
);
}
if (sharingSettingsQuery.data.sharing_disabled) {
return (
<TableBody>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="This workspace cannot be shared"
description="Workspace sharing has been disabled for this organization."
isCompact={isCompact}
/>
</TableCell>
</TableRow>
</TableBody>
);
}
const isEmpty = Boolean(
workspaceACL &&
workspaceACL.users.length === 0 &&
+8 -8
View File
@@ -10,6 +10,14 @@ export const workspaceChecks = (workspace: Workspace) =>
},
action: "read",
},
shareWorkspace: {
object: {
resource_type: "workspace",
resource_id: workspace.id,
owner_id: workspace.owner_id,
},
action: "share",
},
updateWorkspace: {
object: {
resource_type: "workspace",
@@ -34,14 +42,6 @@ export const workspaceChecks = (workspace: Workspace) =>
},
action: "update",
},
// To run a build in debug mode we need to be able to read the deployment
// config (enable_terraform_debug_mode).
deploymentConfig: {
object: {
resource_type: "deployment_config",
},
action: "read",
},
}) satisfies Record<string, AuthorizationCheck>;
export type WorkspacePermissions = Record<
+6 -6
View File
@@ -739,9 +739,9 @@ export const TaskResuming: Story = {
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockStoppedWorkspace,
);
spyOn(API, "startWorkspace").mockResolvedValue(
MockStartingWorkspace.latest_build,
);
spyOn(API, "resumeTask").mockResolvedValue({
workspace_build: MockStartingWorkspace.latest_build,
});
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
},
parameters: {
@@ -766,7 +766,7 @@ export const TaskResuming: Story = {
await userEvent.click(resumeButton);
await waitFor(async () => {
expect(API.startWorkspace).toBeCalled();
expect(API.resumeTask).toBeCalled();
});
},
};
@@ -781,7 +781,7 @@ export const TaskResumeFailure: Story = {
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockStoppedWorkspace,
);
spyOn(API, "startWorkspace").mockRejectedValue(
spyOn(API, "resumeTask").mockRejectedValue(
new Error("Some unexpected error"),
);
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
@@ -820,7 +820,7 @@ export const TaskResumeFailureWithDialog: Story = {
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockStoppedWorkspace,
);
spyOn(API, "startWorkspace").mockRejectedValue({
spyOn(API, "resumeTask").mockRejectedValue({
...mockApiError({
message: "Bad Request",
detail: "Invalid build parameters provided",
+13 -8
View File
@@ -361,7 +361,9 @@ export const PauseTask: Story = {
spyOn(API, "getTasks").mockResolvedValue([
{ ...MockTask, status: "active" },
]);
spyOn(API, "stopWorkspace").mockResolvedValue(MockWorkspaceBuildStop);
spyOn(API, "pauseTask").mockResolvedValue({
workspace_build: MockWorkspaceBuildStop,
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -370,7 +372,10 @@ export const PauseTask: Story = {
});
await userEvent.click(pauseButton);
await waitFor(() => {
expect(API.stopWorkspace).toHaveBeenCalledWith(MockTask.workspace_id);
expect(API.pauseTask).toHaveBeenCalledWith(
MockTask.owner_name,
MockTask.id,
);
});
},
};
@@ -394,7 +399,9 @@ export const ResumeTask: Story = {
spyOn(API, "getTasks").mockResolvedValue([
{ ...MockTask, status: "paused" },
]);
spyOn(API, "startWorkspace").mockResolvedValue(MockWorkspaceBuildStop);
spyOn(API, "resumeTask").mockResolvedValue({
workspace_build: MockWorkspaceBuildStop,
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -403,11 +410,9 @@ export const ResumeTask: Story = {
});
await userEvent.click(resumeButton);
await waitFor(() => {
expect(API.startWorkspace).toHaveBeenCalledWith(
MockTask.workspace_id,
MockTask.template_version_id,
undefined,
undefined,
expect(API.resumeTask).toHaveBeenCalledWith(
MockTask.owner_name,
MockTask.id,
);
});
},
@@ -24,9 +24,9 @@ const createTimestamp = (
const permissions: WorkspacePermissions = {
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deploymentConfig: true,
deleteFailedWorkspace: true,
};
@@ -57,7 +57,6 @@ export const Workspace: FC<WorkspaceProps> = ({
latestVersion,
permissions,
timings,
sharingDisabled,
handleStart,
handleStop,
handleRestart,
@@ -111,7 +110,6 @@ export const Workspace: FC<WorkspaceProps> = ({
latestVersion={latestVersion}
isUpdating={isUpdating}
isRestarting={isRestarting}
sharingDisabled={sharingDisabled}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
@@ -38,6 +38,7 @@ export const ShareButton: FC<ShareButtonProps> = ({
<FeatureStageBadge contentType="beta" size="sm" />
</div>
<WorkspaceSharingForm
organizationId={workspace.organization_id}
workspaceACL={sharing.workspaceACL}
canUpdatePermissions={canUpdatePermissions}
isTaskWorkspace={Boolean(workspace.task_id)}
@@ -16,11 +16,11 @@ const meta: Meta<typeof WorkspaceActions> = {
args: {
isUpdating: false,
permissions: {
deleteFailedWorkspace: true,
deploymentConfig: true,
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
},
},
decorators: [withDashboardProvider, withDesktopViewport, withAuthProvider],
@@ -172,11 +172,11 @@ export const FailedWithDebug: Story = {
args: {
workspace: Mocks.MockFailedWorkspace,
permissions: {
deploymentConfig: true,
deleteFailedWorkspace: true,
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
},
},
};
@@ -29,7 +29,6 @@ interface WorkspaceActionsProps {
isUpdating: boolean;
isRestarting: boolean;
permissions: WorkspacePermissions;
sharingDisabled?: boolean;
handleToggleFavorite: () => void;
handleStart: (buildParameters?: WorkspaceBuildParameter[]) => void;
handleStop: () => void;
@@ -46,7 +45,6 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
isUpdating,
isRestarting,
permissions,
sharingDisabled,
handleToggleFavorite,
handleStart,
handleStop,
@@ -57,10 +55,13 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
handleDebug,
handleDormantActivate,
}) => {
const { user } = useAuthenticated();
const {
permissions: { viewDeploymentConfig },
user,
} = useAuthenticated();
const { data: deployment } = useQuery({
...deploymentConfig(),
enabled: permissions.deploymentConfig,
enabled: viewDeploymentConfig,
});
const { actions, canCancel, canAcceptJobs } = abilitiesByWorkspaceStatus(
workspace,
@@ -191,7 +192,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
onToggle={handleToggleFavorite}
/>
{!sharingDisabled && (
{permissions.shareWorkspace && (
<ShareButton
workspace={workspace}
canUpdatePermissions={permissions.updateWorkspace}
@@ -14,9 +14,9 @@ import { WorkspaceNotifications } from "./WorkspaceNotifications";
export const defaultPermissions: WorkspacePermissions = {
readWorkspace: true,
updateWorkspaceVersion: true,
shareWorkspace: true,
updateWorkspace: true,
deploymentConfig: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
};
@@ -125,11 +125,11 @@ describe("WorkspacePage", () => {
server.use(
http.post("/api/v2/authcheck", async () => {
const permissions: WorkspacePermissions = {
deleteFailedWorkspace: true,
deploymentConfig: true,
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
};
return HttpResponse.json(permissions);
}),
@@ -1,5 +1,4 @@
import { watchWorkspace } from "api/api";
import { workspaceSharingSettings } from "api/queries/organizations";
import { template as templateQueryOptions } from "api/queries/templates";
import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
import {
@@ -45,12 +44,6 @@ const WorkspacePage: FC = () => {
const permissionsQuery = useQuery(workspacePermissions(workspace));
const permissions = permissionsQuery.data;
const sharingSettingsQuery = useQuery({
...workspaceSharingSettings(workspace?.organization_id ?? ""),
enabled: !!workspace,
});
const sharingDisabled = sharingSettingsQuery.data?.sharing_disabled ?? false;
// Watch workspace changes
const updateWorkspaceData = useEffectEvent(
async (newWorkspaceData: Workspace) => {
@@ -121,7 +114,6 @@ const WorkspacePage: FC = () => {
workspace={workspace}
template={template}
permissions={permissions}
sharingDisabled={sharingDisabled}
/>
);
};
@@ -34,14 +34,12 @@ interface WorkspaceReadyPageProps {
template: TypesGen.Template;
workspace: TypesGen.Workspace;
permissions: WorkspacePermissions;
sharingDisabled?: boolean;
}
export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
workspace,
template,
permissions,
sharingDisabled,
}) => {
const queryClient = useQueryClient();
@@ -285,7 +283,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
template={template}
buildLogs={buildLogs}
timings={timingsQuery.data}
sharingDisabled={sharingDisabled}
handleStart={async (buildParameters) => {
const { hasEphemeral, ephemeralParameters } =
await checkEphemeralParameters(buildParameters);
@@ -35,9 +35,9 @@ const meta: Meta<typeof WorkspaceTopbar> = {
latestVersion: MockTemplateVersion,
permissions: {
readWorkspace: true,
updateWorkspaceVersion: true,
shareWorkspace: true,
updateWorkspace: true,
deploymentConfig: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
},
},
@@ -44,7 +44,6 @@ interface WorkspaceProps {
template: TypesGen.Template;
permissions: WorkspacePermissions;
latestVersion?: TypesGen.TemplateVersion;
sharingDisabled?: boolean;
handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
handleStop: () => void;
handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
@@ -63,7 +62,6 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
permissions,
isUpdating,
isRestarting,
sharingDisabled,
handleStart,
handleStop,
handleRestart,
@@ -238,7 +236,6 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
permissions={permissions}
isUpdating={isUpdating}
isRestarting={isRestarting}
sharingDisabled={sharingDisabled}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
@@ -1,4 +1,3 @@
import type { Workspace } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
import {
@@ -12,19 +11,11 @@ import {
TimerIcon as ScheduleIcon,
Users as SharingIcon,
} from "lucide-react";
import type { FC } from "react";
import { useWorkspaceSettings } from "./WorkspaceSettingsLayout";
interface SidebarProps {
username: string;
workspace: Workspace;
sharingDisabled?: boolean;
}
export const Sidebar: React.FC = () => {
const { owner, workspace, permissions } = useWorkspaceSettings();
export const Sidebar: FC<SidebarProps> = ({
username,
workspace,
sharingDisabled,
}) => {
return (
<BaseSidebar>
<SidebarHeader
@@ -36,7 +27,7 @@ export const Sidebar: FC<SidebarProps> = ({
/>
}
title={workspace.name}
linkTo={`/@${username}/${workspace.name}`}
linkTo={`/@${owner}/${workspace.name}`}
subtitle={workspace.template_display_name ?? workspace.template_name}
/>
@@ -49,7 +40,7 @@ export const Sidebar: FC<SidebarProps> = ({
<SidebarNavItem href="schedule" icon={ScheduleIcon}>
Schedule
</SidebarNavItem>
{!sharingDisabled && (
{permissions?.shareWorkspace && (
<SidebarNavItem href="sharing" icon={SharingIcon}>
Sharing
<FeatureStageBadge contentType="beta" size="sm" />
@@ -4,7 +4,7 @@ import WorkspaceParametersPage from "./WorkspaceParametersPage";
import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental";
const WorkspaceParametersExperimentRouter: FC = () => {
const workspace = useWorkspaceSettings();
const { workspace } = useWorkspaceSettings();
return (
<>
@@ -29,7 +29,7 @@ import {
} from "./WorkspaceParametersForm";
const WorkspaceParametersPage: FC = () => {
const workspace = useWorkspaceSettings();
const { workspace } = useWorkspaceSettings();
const build = workspace.latest_build;
const { data: templateVersionParameters } = useQuery(
richParameters(build.template_version_id),
@@ -33,7 +33,7 @@ import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
import { WorkspaceParametersPageViewExperimental } from "./WorkspaceParametersPageViewExperimental";
const WorkspaceParametersPageExperimental: FC = () => {
const workspace = useWorkspaceSettings();
const { workspace } = useWorkspaceSettings();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const templateVersionId = searchParams.get("templateVersionId") ?? undefined;
@@ -6,10 +6,10 @@ import {
} from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { getAuthorizationKey } from "api/queries/authCheck";
import { templateByNameKey } from "api/queries/templates";
import { workspaceByOwnerAndNameKey } from "api/queries/workspaces";
import type { Workspace } from "api/typesGenerated";
import type { WorkspacePermissions } from "modules/workspaces/permissions";
import {
reactRouterOutlet,
reactRouterParameters,
@@ -68,19 +68,14 @@ function workspaceQueries(workspace: Workspace) {
data: workspace,
},
{
key: getAuthorizationKey({
checks: {
updateWorkspace: {
object: {
resource_type: "workspace",
resource_id: MockWorkspace.id,
owner_id: MockWorkspace.owner_id,
},
action: "update",
},
},
}),
data: { updateWorkspace: true },
key: ["workspaces", workspace.id, "permissions"],
data: {
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
} satisfies WorkspacePermissions,
},
{
key: templateByNameKey(
@@ -1,5 +1,4 @@
import { API } from "api/api";
import { checkAuthorization } from "api/queries/authCheck";
import { templateByName } from "api/queries/templates";
import { workspaceByOwnerAndNameKey } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
@@ -28,28 +27,13 @@ import {
} from "./formToRequest";
import { WorkspaceScheduleForm } from "./WorkspaceScheduleForm";
const permissionsToCheck = (workspace: TypesGen.Workspace) =>
({
updateWorkspace: {
object: {
resource_type: "workspace",
resource_id: workspace.id,
owner_id: workspace.owner_id,
},
action: "update",
},
}) as const;
const WorkspaceSchedulePage: FC = () => {
const params = useParams() as { username: string; workspace: string };
const navigate = useNavigate();
const username = params.username.replace("@", "");
const workspaceName = params.workspace;
const queryClient = useQueryClient();
const workspace = useWorkspaceSettings();
const { data: permissions, error: checkPermissionsError } = useQuery(
checkAuthorization({ checks: permissionsToCheck(workspace) }),
);
const { permissions, workspace } = useWorkspaceSettings();
const { data: template, error: getTemplateError } = useQuery(
templateByName(workspace.organization_id, workspace.template_name),
);
@@ -66,8 +50,8 @@ const WorkspaceSchedulePage: FC = () => {
},
onError: () => displayError("Failed to update workspace schedule"),
});
const error = checkPermissionsError || getTemplateError;
const isLoading = !template || !permissions;
const error = getTemplateError;
const isLoading = !template;
const [isConfirmingApply, setIsConfirmingApply] = useState(false);
const { mutate: updateWorkspace } = useMutation({
@@ -1,17 +1,28 @@
import { workspaceSharingSettings } from "api/queries/organizations";
import { workspaceByOwnerAndName } from "api/queries/workspaces";
import {
workspaceByOwnerAndName,
workspacePermissions,
} from "api/queries/workspaces";
import type { Workspace } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { Stack } from "components/Stack/Stack";
import type { WorkspacePermissions } from "modules/workspaces/permissions";
import { createContext, type FC, Suspense, useContext } from "react";
import { useQuery } from "react-query";
import { Outlet, useParams } from "react-router";
import { pageTitle } from "utils/page";
import { Sidebar } from "./Sidebar";
const WorkspaceSettings = createContext<Workspace | undefined>(undefined);
type WorkspaceSettingsContext = {
owner: string;
workspace: Workspace;
permissions?: WorkspacePermissions;
};
const WorkspaceSettings = createContext<WorkspaceSettingsContext | undefined>(
undefined,
);
export function useWorkspaceSettings() {
const value = useContext(WorkspaceSettings);
@@ -31,39 +42,36 @@ export const WorkspaceSettingsLayout: FC = () => {
};
const workspaceName = params.workspace;
const username = params.username.replace("@", "");
const {
data: workspace,
error,
isLoading,
isError,
} = useQuery(workspaceByOwnerAndName(username, workspaceName));
const workspaceQuery = useQuery(
workspaceByOwnerAndName(username, workspaceName),
);
const sharingSettingsQuery = useQuery({
...workspaceSharingSettings(workspace?.organization_id ?? ""),
enabled: !!workspace,
});
const sharingDisabled = sharingSettingsQuery.data?.sharing_disabled ?? false;
const permissionsQuery = useQuery(workspacePermissions(workspaceQuery.data));
if (isLoading) {
if (workspaceQuery.isLoading) {
return <Loader />;
}
const error = workspaceQuery.error || permissionsQuery.error;
return (
<>
<title>{pageTitle(workspaceName, "Settings")}</title>
<Margins>
<Stack css={{ padding: "48px 0" }} direction="row" spacing={10}>
{isError ? (
{error ? (
<ErrorAlert error={error} />
) : (
workspace && (
<WorkspaceSettings.Provider value={workspace}>
<Sidebar
workspace={workspace}
username={username}
sharingDisabled={sharingDisabled}
/>
workspaceQuery.data && (
<WorkspaceSettings.Provider
value={{
owner: username,
workspace: workspaceQuery.data,
permissions: permissionsQuery.data,
}}
>
<Sidebar />
<Suspense fallback={<Loader />}>
<main css={{ width: "100%" }}>
<Outlet />
@@ -15,7 +15,7 @@ const WorkspaceSettingsPage: FC = () => {
};
const workspaceName = params.workspace;
const username = params.username.replace("@", "");
const workspace = useWorkspaceSettings();
const { workspace } = useWorkspaceSettings();
const navigate = useNavigate();
const mutation = useMutation({
@@ -11,7 +11,7 @@ import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
import { WorkspaceSharingPageView } from "./WorkspaceSharingPageView";
const WorkspaceSharingPage: FC = () => {
const workspace = useWorkspaceSettings();
const { workspace } = useWorkspaceSettings();
const sharing = useWorkspaceSharing(workspace);
const checks = workspaceChecks(workspace);
@@ -25,7 +25,7 @@ const WorkspaceSharingPage: FC = () => {
sharing.error ?? permissionsQuery.error ?? sharing.mutationError;
return (
<div className="flex flex-col gap-12 max-w-screen-md">
<div className="flex flex-col gap-12">
<title>{pageTitle(workspace.name, "Sharing")}</title>
<header className="flex flex-col">
@@ -7,6 +7,7 @@ import {
mockApiError,
} from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { getWorkspaceSharingSettingsKey } from "api/queries/organizations";
import type {
WorkspaceACL,
WorkspaceGroup,
@@ -63,6 +64,14 @@ const aclWithUsersAndGroups: WorkspaceACL = {
const meta: Meta<typeof WorkspaceSharingPageView> = {
title: "pages/WorkspaceSharingPageView",
component: WorkspaceSharingPageView,
parameters: {
queries: [
{
key: getWorkspaceSharingSettingsKey(MockWorkspace.organization_id),
data: { sharing_disabled: false },
},
],
},
args: {
workspace: MockWorkspace,
workspaceACL: emptyACL,
@@ -52,6 +52,7 @@ export const WorkspaceSharingPageView: FC<WorkspaceSharingPageViewProps> = ({
}) => {
return (
<WorkspaceSharingForm
organizationId={workspace.organization_id}
workspaceACL={workspaceACL}
canUpdatePermissions={canUpdatePermissions}
isTaskWorkspace={Boolean(workspace.task_id)}