Skip to content

Commit 1c2b543

Browse files
feat: enable renaming providers (#358)
Signed-off-by: Samuel K <skevetter@pm.me> Co-authored-by: Samuel K <skevetter@pm.me>
1 parent 371e52e commit 1c2b543

File tree

19 files changed

+1051
-121
lines changed

19 files changed

+1051
-121
lines changed

cmd/provider/add.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func NewAddCmd(f *flags.GlobalFlags) *cobra.Command {
5656
BoolVar(&cmd.SingleMachine, "single-machine", false, "If enabled will use a single machine for all workspaces")
5757
addCmd.Flags().
5858
StringVar(&cmd.Name, "name", "",
59-
"The name to use for this provider. If empty will use the name within the loaded config")
59+
"The name for the new provider. If not specified, the name from the provider's configuration file will be used.")
6060
addCmd.Flags().
6161
StringVar(&cmd.FromExisting, "from-existing", "",
6262
"The name of an existing provider to use as a template. Needs to be used in conjunction with the --name flag")

cmd/provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ func NewProviderCmd(flags *flags.GlobalFlags) *cobra.Command {
2020
providerCmd.AddCommand(NewAddCmd(flags))
2121
providerCmd.AddCommand(NewUpdateCmd(flags))
2222
providerCmd.AddCommand(NewSetOptionsCmd(flags))
23+
providerCmd.AddCommand(NewRenameCmd(flags))
2324
return providerCmd
2425
}

cmd/provider/rename.go

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/skevetter/devpod/cmd/flags"
10+
"github.com/skevetter/devpod/pkg/config"
11+
"github.com/skevetter/devpod/pkg/provider"
12+
workspace "github.com/skevetter/devpod/pkg/workspace"
13+
"github.com/skevetter/log"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
// RenameCmd implements the provider rename command.
18+
type RenameCmd struct {
19+
*flags.GlobalFlags
20+
}
21+
22+
// NewRenameCmd creates a new command for renaming a provider.
23+
func NewRenameCmd(globalFlags *flags.GlobalFlags) *cobra.Command {
24+
cmd := &RenameCmd{
25+
GlobalFlags: globalFlags,
26+
}
27+
28+
return &cobra.Command{
29+
Use: "rename <current-name> <new-name>",
30+
Short: "Rename a provider",
31+
Args: cobra.ExactArgs(2),
32+
RunE: func(cobraCmd *cobra.Command, args []string) error {
33+
return cmd.Run(cobraCmd.Context(), args)
34+
},
35+
}
36+
}
37+
38+
// Run validates inputs, loads config, and executes the provider rename.
39+
func (cmd *RenameCmd) Run(ctx context.Context, args []string) error {
40+
oldName, newName := args[0], args[1]
41+
42+
if oldName == newName {
43+
return fmt.Errorf("new name is the same as the current name")
44+
}
45+
46+
if err := validateProviderName(newName); err != nil {
47+
return err
48+
}
49+
50+
devPodConfig, err := config.LoadConfig(cmd.Context, cmd.Provider)
51+
if err != nil {
52+
return err
53+
}
54+
55+
if err := validateProviderRename(devPodConfig, oldName); err != nil {
56+
return err
57+
}
58+
59+
if devPodConfig.Current().Providers[newName] != nil {
60+
return fmt.Errorf("provider %s already exists", newName)
61+
}
62+
63+
return renameProvider(ctx, devPodConfig, oldName, newName)
64+
}
65+
66+
// validateProviderName checks that the given name is non-empty, matches the
67+
// allowed character set (lowercase letters, numbers, dashes), and does not
68+
// exceed the maximum length of 32 characters.
69+
func validateProviderName(newName string) error {
70+
if strings.TrimSpace(newName) == "" {
71+
return fmt.Errorf("provider name cannot be empty")
72+
}
73+
if provider.ProviderNameRegEx.MatchString(newName) {
74+
return fmt.Errorf("provider name can only include lowercase letters, numbers or dashes")
75+
}
76+
if len(newName) > 32 {
77+
return fmt.Errorf("provider name cannot be longer than 32 characters")
78+
}
79+
return nil
80+
}
81+
82+
// getWorkspacesForProvider returns all local workspaces whose provider matches
83+
// the given name.
84+
func getWorkspacesForProvider(
85+
devPodConfig *config.Config,
86+
providerName string,
87+
) ([]*provider.Workspace, error) {
88+
workspaces, err := workspace.ListLocalWorkspaces(
89+
devPodConfig.DefaultContext,
90+
false,
91+
log.Default,
92+
)
93+
if err != nil {
94+
return nil, fmt.Errorf("listing workspaces: %w", err)
95+
}
96+
var matched []*provider.Workspace
97+
for _, ws := range workspaces {
98+
if ws.Provider.Name == providerName {
99+
matched = append(matched, ws)
100+
}
101+
}
102+
return matched, nil
103+
}
104+
105+
// getMachinesForProvider returns all machines whose provider matches the given
106+
// name.
107+
func getMachinesForProvider(
108+
devPodConfig *config.Config,
109+
providerName string,
110+
) ([]*provider.Machine, error) {
111+
machines, err := workspace.ListMachines(devPodConfig, log.Default)
112+
if err != nil {
113+
return nil, fmt.Errorf("listing machines: %w", err)
114+
}
115+
var matched []*provider.Machine
116+
for _, m := range machines {
117+
if m.Provider.Name == providerName {
118+
matched = append(matched, m)
119+
}
120+
}
121+
return matched, nil
122+
}
123+
124+
// switchWorkspaces updates each workspace to reference the new provider name.
125+
// It stops on the first failure and returns the successfully switched
126+
// workspaces so the caller can roll them back.
127+
func switchWorkspaces(
128+
ctx context.Context,
129+
devPodConfig *config.Config,
130+
workspaces []*provider.Workspace,
131+
newName string,
132+
) ([]*provider.Workspace, error) {
133+
var switched []*provider.Workspace
134+
for _, ws := range workspaces {
135+
if err := workspace.SwitchProvider(ctx, devPodConfig, ws, newName); err != nil {
136+
return switched, fmt.Errorf("failed to switch workspace %s: %w", ws.ID, err)
137+
}
138+
switched = append(switched, ws)
139+
}
140+
return switched, nil
141+
}
142+
143+
// switchMachines updates each machine to reference the new provider name.
144+
// It stops on the first failure and returns the successfully switched
145+
// machines so the caller can roll them back.
146+
func switchMachines(machines []*provider.Machine, newName string) ([]*provider.Machine, error) {
147+
var switched []*provider.Machine
148+
for _, m := range machines {
149+
oldName := m.Provider.Name
150+
m.Provider.Name = newName
151+
if err := provider.SaveMachineConfig(m); err != nil {
152+
m.Provider.Name = oldName
153+
return switched, fmt.Errorf("failed to switch machine %s: %w", m.ID, err)
154+
}
155+
switched = append(switched, m)
156+
}
157+
return switched, nil
158+
}
159+
160+
// setDefaultProvider updates the default provider setting if it currently
161+
// points to oldName. Returns true if the default was changed.
162+
func setDefaultProvider(devPodConfig *config.Config, oldName, newName string) (bool, error) {
163+
if devPodConfig.Current().DefaultProvider != oldName {
164+
return false, nil
165+
}
166+
devPodConfig.Current().DefaultProvider = newName
167+
if err := config.SaveConfig(devPodConfig); err != nil {
168+
devPodConfig.Current().DefaultProvider = oldName
169+
return false, err
170+
}
171+
return true, nil
172+
}
173+
174+
// renameState tracks the mutations performed during a rename so they can be
175+
// undone if a later step fails.
176+
type renameState struct {
177+
devPodConfig *config.Config
178+
switchedWorkspaces []*provider.Workspace
179+
switchedMachines []*provider.Machine
180+
defaultChanged bool
181+
oldName, newName string
182+
}
183+
184+
// restoreProviderState reverts all recorded mutations in reverse order: default provider,
185+
// workspaces, machines, and finally the provider directory move.
186+
func (r *renameState) restoreProviderState(ctx context.Context) error {
187+
log.Default.Info("rolling back changes")
188+
var errs error
189+
190+
if r.defaultChanged {
191+
r.devPodConfig.Current().DefaultProvider = r.oldName
192+
if err := config.SaveConfig(r.devPodConfig); err != nil {
193+
errs = errors.Join(errs, fmt.Errorf("rollback default provider: %w", err))
194+
}
195+
}
196+
197+
_, err := switchWorkspaces(ctx, r.devPodConfig, r.switchedWorkspaces, r.oldName)
198+
errs = errors.Join(errs, err)
199+
200+
_, err = switchMachines(r.switchedMachines, r.oldName)
201+
errs = errors.Join(errs, err)
202+
203+
if moveErr := workspace.MoveProvider(r.devPodConfig, r.newName, r.oldName); moveErr != nil {
204+
errs = errors.Join(errs, fmt.Errorf("rollback move provider: %w", moveErr))
205+
}
206+
207+
return errs
208+
}
209+
210+
// validateProviderRename verifies that the provider exists, is not a pro
211+
// provider, is not backing a pro instance, and has configuration state.
212+
func validateProviderRename(devPodConfig *config.Config, oldName string) error {
213+
providerWithOptions, err := workspace.FindProvider(devPodConfig, oldName, log.Default)
214+
if err != nil {
215+
return fmt.Errorf("provider %s not found", oldName)
216+
}
217+
218+
if providerWithOptions.Config.IsProxyProvider() ||
219+
providerWithOptions.Config.IsDaemonProvider() {
220+
return fmt.Errorf("cannot rename a pro provider; pro providers are managed by the platform")
221+
}
222+
223+
proInstances, err := workspace.ListProInstances(devPodConfig, log.Default)
224+
if err != nil {
225+
return fmt.Errorf("listing pro instances: %w", err)
226+
}
227+
for _, inst := range proInstances {
228+
if inst.Provider == oldName {
229+
return fmt.Errorf(
230+
"cannot rename provider %s: it is used by pro instance %s",
231+
oldName,
232+
inst.Host,
233+
)
234+
}
235+
}
236+
237+
if devPodConfig.Current().Providers[oldName] == nil {
238+
return fmt.Errorf("provider %s has no configuration state", oldName)
239+
}
240+
241+
return nil
242+
}
243+
244+
// renameProvider performs the rename: moves the provider directory, switches all
245+
// associated workspaces and machines, and adjusts the default provider. If any
246+
// step fails the entire operation is rolled back.
247+
func renameProvider(
248+
ctx context.Context,
249+
devPodConfig *config.Config,
250+
oldName, newName string,
251+
) error {
252+
workspaces, err := getWorkspacesForProvider(devPodConfig, oldName)
253+
if err != nil {
254+
return err
255+
}
256+
257+
machines, err := getMachinesForProvider(devPodConfig, oldName)
258+
if err != nil {
259+
return err
260+
}
261+
262+
if err := workspace.MoveProvider(devPodConfig, oldName, newName); err != nil {
263+
return fmt.Errorf("moving provider: %w", err)
264+
}
265+
266+
rb := &renameState{devPodConfig: devPodConfig, oldName: oldName, newName: newName}
267+
268+
rb.switchedWorkspaces, err = switchWorkspaces(ctx, devPodConfig, workspaces, newName)
269+
if err != nil {
270+
return errors.Join(err, rb.restoreProviderState(ctx))
271+
}
272+
273+
rb.switchedMachines, err = switchMachines(machines, newName)
274+
if err != nil {
275+
return errors.Join(err, rb.restoreProviderState(ctx))
276+
}
277+
278+
rb.defaultChanged, err = setDefaultProvider(devPodConfig, oldName, newName)
279+
if err != nil {
280+
return errors.Join(err, rb.restoreProviderState(ctx))
281+
}
282+
283+
log.Default.Donef("renamed provider %s to %s", oldName, newName)
284+
return nil
285+
}

desktop/src/client/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const DEVPOD_COMMAND_USE = "use"
2323
export const DEVPOD_COMMAND_ADD = "add"
2424
export const DEVPOD_COMMAND_HELPER = "helper"
2525
export const DEVPOD_COMMAND_UPDATE = "update"
26+
export const DEVPOD_COMMAND_RENAME = "rename"
2627
export const DEVPOD_COMMAND_CONTEXT = "context"
2728
export const DEVPOD_COMMAND_LOGIN = "login"
2829
export const DEVPOD_COMMAND_IMPORT_WORKSPACE = "import-workspace"

desktop/src/client/providers/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export class ProvidersClient implements TDebuggable {
5757
return ProviderCommands.RemoveProvider(id)
5858
}
5959

60+
public async rename(id: TProviderID, newName: string): Promise<ResultError> {
61+
return ProviderCommands.RenameProvider(id, newName)
62+
}
63+
6064
public async getOptions(id: TProviderID): Promise<Result<TProviderOptions>> {
6165
return ProviderCommands.GetProviderOptions(id)
6266
}

desktop/src/client/providers/providerCommands.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
DEVPOD_COMMAND_LIST,
1616
DEVPOD_COMMAND_OPTIONS,
1717
DEVPOD_COMMAND_PROVIDER,
18+
DEVPOD_COMMAND_RENAME,
1819
DEVPOD_COMMAND_SET_OPTIONS,
1920
DEVPOD_COMMAND_UPDATE,
2021
DEVPOD_COMMAND_USE,
@@ -243,4 +244,23 @@ export class ProviderCommands {
243244

244245
return Return.Ok()
245246
}
247+
248+
static async RenameProvider(oldName: TProviderID, newName: string) {
249+
const result = await ProviderCommands.newCommand([
250+
DEVPOD_COMMAND_PROVIDER,
251+
DEVPOD_COMMAND_RENAME,
252+
oldName,
253+
newName,
254+
DEVPOD_FLAG_JSON_LOG_OUTPUT,
255+
]).run()
256+
if (result.err) {
257+
return result
258+
}
259+
260+
if (!isOk(result.val)) {
261+
return getErrorFromChildProcess(result.val)
262+
}
263+
264+
return Return.Ok()
265+
}
246266
}

0 commit comments

Comments
 (0)