feat: allow users to pause prebuilt workspace reconciliation (#18700)

This PR provides two commands:
* `coder prebuilds pause`
* `coder prebuilds resume`

These allow the suspension of all prebuilds activity, intended for use
if prebuilds are misbehaving.
This commit is contained in:
Sas Swart
2025-07-02 15:05:42 +00:00
committed by GitHub
parent 4072d228c5
commit 01163ea57b
42 changed files with 1336 additions and 4 deletions
+86
View File
@@ -0,0 +1,86 @@
package cli
import (
"fmt"
"golang.org/x/xerrors"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/codersdk"
)
func (r *RootCmd) prebuilds() *serpent.Command {
cmd := &serpent.Command{
Use: "prebuilds",
Short: "Manage Coder prebuilds",
Long: "Administrators can use these commands to manage prebuilt workspace settings.\n" + cli.FormatExamples(
cli.Example{
Description: "Pause Coder prebuilt workspace reconciliation.",
Command: "coder prebuilds pause",
},
cli.Example{
Description: "Resume Coder prebuilt workspace reconciliation if it has been paused.",
Command: "coder prebuilds resume",
},
),
Aliases: []string{"prebuild"},
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.pausePrebuilds(),
r.resumePrebuilds(),
},
}
return cmd
}
func (r *RootCmd) pausePrebuilds() *serpent.Command {
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "pause",
Short: "Pause prebuilds",
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
err := client.PutPrebuildsSettings(inv.Context(), codersdk.PrebuildsSettings{
ReconciliationPaused: true,
})
if err != nil {
return xerrors.Errorf("unable to pause prebuilds: %w", err)
}
_, _ = fmt.Fprintln(inv.Stderr, "Prebuilds are now paused.")
return nil
},
}
return cmd
}
func (r *RootCmd) resumePrebuilds() *serpent.Command {
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "resume",
Short: "Resume prebuilds",
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
err := client.PutPrebuildsSettings(inv.Context(), codersdk.PrebuildsSettings{
ReconciliationPaused: false,
})
if err != nil {
return xerrors.Errorf("unable to resume prebuilds: %w", err)
}
_, _ = fmt.Fprintln(inv.Stderr, "Prebuilds are now resumed.")
return nil
},
}
return cmd
}
+343
View File
@@ -0,0 +1,343 @@
package cli_test
import (
"bytes"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
)
func TestPrebuildsPause(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
inv, conf := newCLI(t, "prebuilds", "pause")
var buf bytes.Buffer
inv.Stderr = &buf
//nolint:gocritic // Only owners can change deployment settings
clitest.SetupConfig(t, client, conf)
err := inv.Run()
require.NoError(t, err)
// Verify the output message
assert.Contains(t, buf.String(), "Prebuilds are now paused.")
// Verify the settings were actually updated
//nolint:gocritic // Only owners can change deployment settings
settings, err := client.GetPrebuildsSettings(inv.Context())
require.NoError(t, err)
assert.True(t, settings.ReconciliationPaused)
})
t.Run("UnauthorizedUser", func(t *testing.T) {
t.Parallel()
adminClient, admin := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
// Create a regular user without admin privileges
client, _ := coderdtest.CreateAnotherUser(t, adminClient, admin.OrganizationID)
inv, conf := newCLI(t, "prebuilds", "pause")
clitest.SetupConfig(t, client, conf)
err := inv.Run()
require.Error(t, err)
var sdkError *codersdk.Error
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
})
t.Run("NoLicense", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
DontAddLicense: true,
})
inv, conf := newCLI(t, "prebuilds", "pause")
//nolint:gocritic // Only owners can change deployment settings
clitest.SetupConfig(t, client, conf)
err := inv.Run()
require.Error(t, err)
// Should fail without license
var sdkError *codersdk.Error
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
})
t.Run("AlreadyPaused", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
// First pause
inv1, conf := newCLI(t, "prebuilds", "pause")
//nolint:gocritic // Only owners can change deployment settings
clitest.SetupConfig(t, client, conf)
err := inv1.Run()
require.NoError(t, err)
// Try to pause again
inv2, conf2 := newCLI(t, "prebuilds", "pause")
clitest.SetupConfig(t, client, conf2)
err = inv2.Run()
require.NoError(t, err) // Should succeed even if already paused
// Verify still paused
//nolint:gocritic // Only owners can change deployment settings
settings, err := client.GetPrebuildsSettings(inv2.Context())
require.NoError(t, err)
assert.True(t, settings.ReconciliationPaused)
})
}
func TestPrebuildsResume(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
// First pause prebuilds
inv1, conf := newCLI(t, "prebuilds", "pause")
//nolint:gocritic // Only owners can change deployment settings
clitest.SetupConfig(t, client, conf)
err := inv1.Run()
require.NoError(t, err)
// Then resume
inv2, conf2 := newCLI(t, "prebuilds", "resume")
var buf bytes.Buffer
inv2.Stderr = &buf
clitest.SetupConfig(t, client, conf2)
err = inv2.Run()
require.NoError(t, err)
// Verify the output message
assert.Contains(t, buf.String(), "Prebuilds are now resumed.")
// Verify the settings were actually updated
//nolint:gocritic // Only owners can change deployment settings
settings, err := client.GetPrebuildsSettings(inv2.Context())
require.NoError(t, err)
assert.False(t, settings.ReconciliationPaused)
})
t.Run("ResumeWhenNotPaused", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
// Resume without first pausing
inv, conf := newCLI(t, "prebuilds", "resume")
var buf bytes.Buffer
inv.Stderr = &buf
//nolint:gocritic // Only owners can change deployment settings
clitest.SetupConfig(t, client, conf)
err := inv.Run()
require.NoError(t, err)
// Should succeed and show the message
assert.Contains(t, buf.String(), "Prebuilds are now resumed.")
// Verify still not paused
//nolint:gocritic // Only owners can change deployment settings
settings, err := client.GetPrebuildsSettings(inv.Context())
require.NoError(t, err)
assert.False(t, settings.ReconciliationPaused)
})
t.Run("UnauthorizedUser", func(t *testing.T) {
t.Parallel()
adminClient, admin := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
// Create a regular user without admin privileges
client, _ := coderdtest.CreateAnotherUser(t, adminClient, admin.OrganizationID)
inv, conf := newCLI(t, "prebuilds", "resume")
clitest.SetupConfig(t, client, conf)
err := inv.Run()
require.Error(t, err)
var sdkError *codersdk.Error
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
})
t.Run("NoLicense", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
DontAddLicense: true,
})
inv, conf := newCLI(t, "prebuilds", "resume")
//nolint:gocritic // Only owners can change deployment settings
clitest.SetupConfig(t, client, conf)
err := inv.Run()
require.Error(t, err)
// Should fail without license
var sdkError *codersdk.Error
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
})
}
func TestPrebuildsCommand(t *testing.T) {
t.Parallel()
t.Run("Help", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
inv, conf := newCLI(t, "prebuilds", "--help")
var buf bytes.Buffer
inv.Stdout = &buf
//nolint:gocritic // Only owners can change deployment settings
clitest.SetupConfig(t, client, conf)
err := inv.Run()
require.NoError(t, err)
// Verify help output contains expected information
output := buf.String()
assert.Contains(t, output, "Manage Coder prebuilds")
assert.Contains(t, output, "pause")
assert.Contains(t, output, "resume")
assert.Contains(t, output, "Administrators can use these commands")
})
t.Run("NoSubcommand", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
inv, conf := newCLI(t, "prebuilds")
var buf bytes.Buffer
inv.Stdout = &buf
//nolint:gocritic // Only owners can change deployment settings
clitest.SetupConfig(t, client, conf)
err := inv.Run()
require.NoError(t, err)
// Should show help when no subcommand is provided
output := buf.String()
assert.Contains(t, output, "Manage Coder prebuilds")
assert.Contains(t, output, "pause")
assert.Contains(t, output, "resume")
})
}
func TestPrebuildsSettingsAPI(t *testing.T) {
t.Parallel()
t.Run("GetSettings", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
// Get initial settings
//nolint:gocritic // Only owners can change deployment settings
settings, err := client.GetPrebuildsSettings(t.Context())
require.NoError(t, err)
assert.False(t, settings.ReconciliationPaused)
// Pause prebuilds
inv1, conf := newCLI(t, "prebuilds", "pause")
//nolint:gocritic // Only owners can change deployment settings
clitest.SetupConfig(t, client, conf)
err = inv1.Run()
require.NoError(t, err)
// Get settings again
settings, err = client.GetPrebuildsSettings(t.Context())
require.NoError(t, err)
assert.True(t, settings.ReconciliationPaused)
// Resume prebuilds
inv2, conf2 := newCLI(t, "prebuilds", "resume")
clitest.SetupConfig(t, client, conf2)
err = inv2.Run()
require.NoError(t, err)
// Get settings one more time
settings, err = client.GetPrebuildsSettings(t.Context())
require.NoError(t, err)
assert.False(t, settings.ReconciliationPaused)
})
}
+1
View File
@@ -16,6 +16,7 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command {
r.features(),
r.licenses(),
r.groups(),
r.prebuilds(),
r.provisionerDaemons(),
r.provisionerd(),
}
+1
View File
@@ -17,6 +17,7 @@ SUBCOMMANDS:
features List Enterprise features
groups Manage groups
licenses Add, delete, and list licenses
prebuilds Manage Coder prebuilds
provisioner View and manage provisioner daemons and jobs
server Start a Coder server
+24
View File
@@ -0,0 +1,24 @@
coder v0.0.0-devel
USAGE:
coder prebuilds
Manage Coder prebuilds
Aliases: prebuild
Administrators can use these commands to manage prebuilt workspace settings.
- Pause Coder prebuilt workspace reconciliation.:
$ coder prebuilds pause
- Resume Coder prebuilt workspace reconciliation if it has been paused.:
$ coder prebuilds resume
SUBCOMMANDS:
pause Pause prebuilds
resume Resume prebuilds
———
Run `coder --help` for a list of global options.
@@ -0,0 +1,9 @@
coder v0.0.0-devel
USAGE:
coder prebuilds pause
Pause prebuilds
———
Run `coder --help` for a list of global options.
@@ -0,0 +1,9 @@
coder v0.0.0-devel
USAGE:
coder prebuilds resume
Resume prebuilds
———
Run `coder --help` for a list of global options.