diff --git a/cmd/project_rename.go b/cmd/project_rename.go new file mode 100644 index 000000000..93ec27500 --- /dev/null +++ b/cmd/project_rename.go @@ -0,0 +1,116 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "fmt" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/api" + "github.com/GoogleCloudPlatform/scion/pkg/config" + "github.com/GoogleCloudPlatform/scion/pkg/hubclient" + "github.com/spf13/cobra" +) + +var projectRenameCmd = &cobra.Command{ + Use: "rename ", + Short: "Rename a project", + Long: `Rename a project's display name and slug. + +The argument can be a project name, slug, or ID. The +argument becomes both the new display name and the basis for the new slug +(generated by converting the name to a URL-safe form). + +Renaming a project updates its slug, which affects: + - Filesystem paths for hub-managed project workspaces + - Group identifiers (project:slug:agents, project:slug:members) + +Renaming does NOT affect: + - The project's unique ID (agents reference projects by ID) + - Git remote configuration + - Agent records or running agents + +Running agents retain the old slug until restarted. New agents will use the +updated slug immediately. + +Requires Hub connectivity.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + projectRef := args[0] + newName := args[1] + + newSlug := api.Slugify(newName) + if newSlug == "" { + return fmt.Errorf("invalid new name: must contain at least one alphanumeric character") + } + + resolvedPath, _, err := config.ResolveProjectPath(projectPath) + if err != nil { + return fmt.Errorf("failed to resolve project path: %w", err) + } + + settings, err := config.LoadSettings(resolvedPath) + if err != nil { + return fmt.Errorf("failed to load settings: %w", err) + } + + client, err := getHubClient(settings) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + project, err := resolveProjectByNameOrID(ctx, client, projectRef) + if err != nil { + return fmt.Errorf("failed to find project: %w", err) + } + + updated, err := client.Projects().Update(ctx, project.ID, &hubclient.UpdateProjectRequest{ + Name: newName, + Slug: newSlug, + }) + if err != nil { + return fmt.Errorf("failed to rename project: %w", err) + } + + if isJSONOutput() { + return outputJSON(ActionResult{ + Status: "success", + Command: "project rename", + Message: fmt.Sprintf("Project renamed from %q to %q", project.Name, updated.Name), + Details: map[string]interface{}{ + "id": updated.ID, + "name": updated.Name, + "slug": updated.Slug, + "old_name": project.Name, + "old_slug": project.Slug, + }, + }) + } + + fmt.Printf("Project renamed: %s → %s\n", project.Name, updated.Name) + if project.Slug != updated.Slug { + fmt.Printf("Slug updated: %s → %s\n", project.Slug, updated.Slug) + } + return nil + }, +} + +func init() { + projectCmd.AddCommand(projectRenameCmd) +} diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index a995830f8..24e6ad24c 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -5267,6 +5267,7 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string var updates struct { Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` Labels map[string]string `json:"labels,omitempty"` Visibility string `json:"visibility,omitempty"` DefaultRuntimeBrokerID string `json:"defaultRuntimeBrokerId,omitempty"` @@ -5277,9 +5278,27 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string return } + oldSlug := project.Slug + if updates.Name != "" { project.Name = updates.Name } + if updates.Slug != "" { + newSlug := api.Slugify(updates.Slug) + if newSlug == "" { + BadRequest(w, "Invalid slug: must contain at least one alphanumeric character") + return + } + if newSlug != oldSlug { + existing, err := s.store.GetProjectBySlug(ctx, newSlug) + if err == nil && existing.ID != project.ID { + writeError(w, http.StatusConflict, ErrCodeConflict, + fmt.Sprintf("A project with slug %q already exists", newSlug), nil) + return + } + project.Slug = newSlug + } + } if updates.Labels != nil { project.Labels = updates.Labels } @@ -5295,11 +5314,61 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string return } + // If the slug changed, update associated group slugs and filesystem paths. + if project.Slug != oldSlug { + s.migrateProjectSlug(ctx, project, oldSlug) + } + s.events.PublishProjectUpdated(ctx, project) writeJSON(w, http.StatusOK, project) } +// migrateProjectSlug updates group slugs and filesystem paths after a project slug change. +// This is best-effort: failures are logged but don't roll back the rename. +func (s *Server) migrateProjectSlug(ctx context.Context, project *store.Project, oldSlug string) { + newSlug := project.Slug + + // Migrate the project agents group slug. + oldAgentsSlug := "project:" + oldSlug + ":agents" + newAgentsSlug := "project:" + newSlug + ":agents" + if group, err := s.store.GetGroupBySlug(ctx, oldAgentsSlug); err == nil { + group.Slug = newAgentsSlug + group.Name = project.Name + " Agents" + if err := s.store.UpdateGroup(ctx, group); err != nil { + slog.Warn("failed to migrate project agents group slug", + "project_id", project.ID, "old_slug", oldAgentsSlug, "new_slug", newAgentsSlug, "error", err) + } + } + + // Migrate the project members group slug. + oldMembersSlug := "project:" + oldSlug + ":members" + newMembersSlug := "project:" + newSlug + ":members" + if group, err := s.store.GetGroupBySlug(ctx, oldMembersSlug); err == nil { + group.Slug = newMembersSlug + group.Name = project.Name + " Members" + if err := s.store.UpdateGroup(ctx, group); err != nil { + slog.Warn("failed to migrate project members group slug", + "project_id", project.ID, "old_slug", oldMembersSlug, "new_slug", newMembersSlug, "error", err) + } + } + + // Migrate hub-managed project filesystem paths (best-effort). + if oldPath, err := hubManagedProjectPath(oldSlug); err == nil { + if newPath, err := hubManagedProjectPath(newSlug); err == nil { + // Only rename if the old path exists and the new path doesn't. + if _, statErr := os.Stat(oldPath); statErr == nil { + if _, statErr := os.Stat(newPath); os.IsNotExist(statErr) { + if err := os.Rename(oldPath, newPath); err != nil { + slog.Warn("failed to rename project workspace directory", + "project_id", project.ID, "old_path", oldPath, "new_path", newPath, "error", err) + } + } + } + } + } +} + func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request, id string) { ctx := r.Context() diff --git a/pkg/hub/handlers_test.go b/pkg/hub/handlers_test.go index 8fb4d6715..c5695c32f 100644 --- a/pkg/hub/handlers_test.go +++ b/pkg/hub/handlers_test.go @@ -2407,6 +2407,288 @@ func TestProjectCreateWithSlug(t *testing.T) { } } +// ============================================================================ +// Project Rename Tests +// ============================================================================ + +func TestProjectRenameSlug(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + project := &store.Project{ + ID: tid("project_rename1"), + Slug: "old-slug", + Name: "Old Name", + Created: time.Now(), + Updated: time.Now(), + } + if err := s.CreateProject(ctx, project); err != nil { + t.Fatalf("failed to create project: %v", err) + } + + body := map[string]interface{}{ + "name": "New Name", + "slug": "new-slug", + } + + rec := doRequest(t, srv, http.MethodPatch, fmt.Sprintf("/api/v1/projects/%s", tid("project_rename1")), body) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp store.Project + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Name != "New Name" { + t.Errorf("expected name %q, got %q", "New Name", resp.Name) + } + if resp.Slug != "new-slug" { + t.Errorf("expected slug %q, got %q", "new-slug", resp.Slug) + } + + // Verify the project was actually updated in the store + updated, err := s.GetProject(ctx, tid("project_rename1")) + if err != nil { + t.Fatalf("failed to get project: %v", err) + } + if updated.Slug != "new-slug" { + t.Errorf("store slug not updated: expected %q, got %q", "new-slug", updated.Slug) + } +} + +func TestProjectRenameSlugConflict(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + // Create two projects + project1 := &store.Project{ + ID: tid("project_rename_a"), + Slug: "project-a", + Name: "Project A", + Created: time.Now(), + Updated: time.Now(), + } + project2 := &store.Project{ + ID: tid("project_rename_b"), + Slug: "project-b", + Name: "Project B", + Created: time.Now(), + Updated: time.Now(), + } + if err := s.CreateProject(ctx, project1); err != nil { + t.Fatalf("failed to create project1: %v", err) + } + if err := s.CreateProject(ctx, project2); err != nil { + t.Fatalf("failed to create project2: %v", err) + } + + // Try to rename project-a to project-b's slug + body := map[string]interface{}{ + "slug": "project-b", + } + + rec := doRequest(t, srv, http.MethodPatch, fmt.Sprintf("/api/v1/projects/%s", tid("project_rename_a")), body) + + if rec.Code != http.StatusConflict { + t.Errorf("expected status 409 (conflict), got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestProjectRenameSlugOnly(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + project := &store.Project{ + ID: tid("project_rename_slug"), + Slug: "original-slug", + Name: "Original Name", + Created: time.Now(), + Updated: time.Now(), + } + if err := s.CreateProject(ctx, project); err != nil { + t.Fatalf("failed to create project: %v", err) + } + + // Rename slug only (no name change) + body := map[string]interface{}{ + "slug": "renamed-slug", + } + + rec := doRequest(t, srv, http.MethodPatch, fmt.Sprintf("/api/v1/projects/%s", tid("project_rename_slug")), body) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp store.Project + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Slug != "renamed-slug" { + t.Errorf("expected slug %q, got %q", "renamed-slug", resp.Slug) + } + if resp.Name != "Original Name" { + t.Errorf("name should not change: expected %q, got %q", "Original Name", resp.Name) + } +} + +func TestProjectRenameSlugSanitized(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + project := &store.Project{ + ID: tid("project_rename_san"), + Slug: "sanitize-test", + Name: "Sanitize Test", + Created: time.Now(), + Updated: time.Now(), + } + if err := s.CreateProject(ctx, project); err != nil { + t.Fatalf("failed to create project: %v", err) + } + + // Slug with spaces and uppercase should be sanitized + body := map[string]interface{}{ + "slug": "My New Project", + } + + rec := doRequest(t, srv, http.MethodPatch, fmt.Sprintf("/api/v1/projects/%s", tid("project_rename_san")), body) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp store.Project + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Slug != "my-new-project" { + t.Errorf("expected sanitized slug %q, got %q", "my-new-project", resp.Slug) + } +} + +func TestProjectRenameSameSlugNoOp(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + project := &store.Project{ + ID: tid("project_rename_noop"), + Slug: "same-slug", + Name: "Same Slug", + Created: time.Now(), + Updated: time.Now(), + } + if err := s.CreateProject(ctx, project); err != nil { + t.Fatalf("failed to create project: %v", err) + } + + body := map[string]interface{}{ + "slug": "same-slug", + "name": "Updated Name", + } + + rec := doRequest(t, srv, http.MethodPatch, fmt.Sprintf("/api/v1/projects/%s", tid("project_rename_noop")), body) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp store.Project + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Name != "Updated Name" { + t.Errorf("expected name %q, got %q", "Updated Name", resp.Name) + } + if resp.Slug != "same-slug" { + t.Errorf("slug should remain %q, got %q", "same-slug", resp.Slug) + } +} + +func TestProjectRenameGroupMigration(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + project := &store.Project{ + ID: tid("project_rename_grp"), + Slug: "grp-old", + Name: "Group Test", + Created: time.Now(), + Updated: time.Now(), + CreatedBy: "test-user", + } + if err := s.CreateProject(ctx, project); err != nil { + t.Fatalf("failed to create project: %v", err) + } + + // Create associated groups (mimicking what createProject does) + agentsGroup := &store.Group{ + ID: api.NewUUID(), + Name: "Group Test Agents", + Slug: "project:grp-old:agents", + GroupType: store.GroupTypeProjectAgents, + ProjectID: tid("project_rename_grp"), + CreatedBy: "test-user", + } + membersGroup := &store.Group{ + ID: api.NewUUID(), + Name: "Group Test Members", + Slug: "project:grp-old:members", + GroupType: store.GroupTypeExplicit, + ProjectID: tid("project_rename_grp"), + CreatedBy: "test-user", + } + if err := s.CreateGroup(ctx, agentsGroup); err != nil { + t.Fatalf("failed to create agents group: %v", err) + } + if err := s.CreateGroup(ctx, membersGroup); err != nil { + t.Fatalf("failed to create members group: %v", err) + } + + // Rename the project + body := map[string]interface{}{ + "name": "Group Test Renamed", + "slug": "grp-new", + } + + rec := doRequest(t, srv, http.MethodPatch, fmt.Sprintf("/api/v1/projects/%s", tid("project_rename_grp")), body) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + // Verify groups were migrated + newAgentsGroup, err := s.GetGroupBySlug(ctx, "project:grp-new:agents") + if err != nil { + t.Errorf("agents group not migrated: %v", err) + } else if newAgentsGroup.Name != "Group Test Renamed Agents" { + t.Errorf("agents group name not updated: got %q", newAgentsGroup.Name) + } + + newMembersGroup, err := s.GetGroupBySlug(ctx, "project:grp-new:members") + if err != nil { + t.Errorf("members group not migrated: %v", err) + } else if newMembersGroup.Name != "Group Test Renamed Members" { + t.Errorf("members group name not updated: got %q", newMembersGroup.Name) + } + + // Old slugs should no longer exist + _, err = s.GetGroupBySlug(ctx, "project:grp-old:agents") + if err == nil { + t.Error("old agents group slug should not exist after migration") + } + _, err = s.GetGroupBySlug(ctx, "project:grp-old:members") + if err == nil { + t.Error("old members group slug should not exist after migration") + } +} + // ============================================================================ // Template Slug Display Tests // ============================================================================ diff --git a/pkg/hubclient/projects.go b/pkg/hubclient/projects.go index 8f1b4971e..50575f362 100644 --- a/pkg/hubclient/projects.go +++ b/pkg/hubclient/projects.go @@ -164,6 +164,7 @@ type CreateProjectRequest struct { // UpdateProjectRequest is the request for updating a project. type UpdateProjectRequest struct { Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` Labels map[string]string `json:"labels,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` Visibility string `json:"visibility,omitempty"`