diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 3770c79e..d088c8e9 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -98,6 +98,7 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { parent.AddCommand(bootstrapGcpCmd.cmd) AddBootstrapGcpPostconfigCmd(bootstrapGcpCmd.cmd, opts) + AddBootstrapGcpCleanupCmd(bootstrapGcpCmd.cmd, opts) } func (c *BootstrapGcpCmd) BootstrapGcp() error { diff --git a/cli/cmd/bootstrap_gcp_cleanup.go b/cli/cmd/bootstrap_gcp_cleanup.go new file mode 100644 index 00000000..6bc6adb6 --- /dev/null +++ b/cli/cmd/bootstrap_gcp_cleanup.go @@ -0,0 +1,222 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bufio" + "fmt" + "io" + "log" + "os" + "strings" + + csio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/bootstrap/gcp" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" +) + +type BootstrapGcpCleanupCmd struct { + cmd *cobra.Command + Opts *BootstrapGcpCleanupOpts +} + +type BootstrapGcpCleanupOpts struct { + *GlobalOptions + ProjectID string + Force bool + SkipDNSCleanup bool + BaseDomain string + DNSZoneName string + DNSProjectID string +} + +type CleanupDeps struct { + GCPClient gcp.GCPClientManager + FileIO util.FileIO + StepLogger *bootstrap.StepLogger + ConfirmReader io.Reader + InfraFilePath string +} + +func (c *BootstrapGcpCleanupCmd) RunE(_ *cobra.Command, args []string) error { + ctx := c.cmd.Context() + stlog := bootstrap.NewStepLogger(false) + gcpClient := gcp.NewGCPClient(ctx, stlog, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")) + fw := util.NewFilesystemWriter() + + deps := &CleanupDeps{ + GCPClient: gcpClient, + FileIO: fw, + StepLogger: stlog, + ConfirmReader: os.Stdin, + InfraFilePath: gcp.GetInfraFilePath(), + } + + return c.ExecuteCleanup(deps) +} + +func (c *BootstrapGcpCleanupCmd) confirmDeletion(deps *CleanupDeps, projectID string) error { + log.Printf("WARNING: This will permanently delete the GCP project '%s' and all its resources.", projectID) + log.Printf("This action cannot be undone.\n") + log.Println("Type the project ID to confirm deletion: ") + + reader := bufio.NewReader(deps.ConfirmReader) + confirmation, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } + if strings.TrimSpace(confirmation) != projectID { + return fmt.Errorf("confirmation did not match project ID, aborting cleanup") + } + return nil +} + +// ExecuteCleanup performs the cleanup operation with the provided dependencies. +func (c *BootstrapGcpCleanupCmd) ExecuteCleanup(deps *CleanupDeps) error { + projectID := c.Opts.ProjectID + infraFileLoaded := false + infraFileExists := false + var infraEnv gcp.CodesphereEnvironment + + // Only load infra file if we need information from it (project ID or DNS info) + missingDNSInfo := c.Opts.BaseDomain == "" || c.Opts.DNSZoneName == "" || c.Opts.DNSProjectID == "" + needsInfraFile := projectID == "" || (!c.Opts.SkipDNSCleanup && missingDNSInfo) + if needsInfraFile { + var err error + infraEnv, infraFileExists, err = gcp.LoadInfraFile(deps.FileIO, deps.InfraFilePath) + if err != nil { + if projectID == "" { + return fmt.Errorf("failed to load infra file: %w", err) + } + log.Printf("Warning: %v", err) + infraEnv = gcp.CodesphereEnvironment{} + } else if infraEnv.ProjectID != "" { + infraFileLoaded = true + } + } + + // Determine project ID to use + if projectID == "" { + if infraFileExists && infraEnv.ProjectID == "" { + return fmt.Errorf("infra file at %s contains empty project ID", deps.InfraFilePath) + } + if infraEnv.ProjectID == "" { + return fmt.Errorf("no project ID provided and no infra file found at %s", deps.InfraFilePath) + } + projectID = infraEnv.ProjectID + log.Printf("Using project ID from infra file: %s", projectID) + } else if infraFileLoaded && infraEnv.ProjectID != projectID { + log.Printf("Warning: infra file contains project ID '%s' but deleting '%s'; ignoring infra file for DNS cleanup", infraEnv.ProjectID, projectID) + infraEnv = gcp.CodesphereEnvironment{} + infraFileLoaded = false + } + + // Apply command-line overrides for DNS settings + baseDomain := c.Opts.BaseDomain + if baseDomain == "" { + baseDomain = infraEnv.BaseDomain + } + dnsZoneName := c.Opts.DNSZoneName + if dnsZoneName == "" { + dnsZoneName = infraEnv.DNSZoneName + } + + // Verify project is OMS-managed + if c.Opts.Force { + log.Printf("Skipping OMS-managed verification and deletion confirmation (--force flag used)") + } else { + isOMSManaged, err := deps.GCPClient.IsOMSManagedProject(projectID) + if err != nil { + return fmt.Errorf("failed to verify project: %w", err) + } + if !isOMSManaged { + return fmt.Errorf("project %s was not bootstrapped by OMS (missing 'oms-managed' label). Use --force to override this check", projectID) + } + + if err := c.confirmDeletion(deps, projectID); err != nil { + return fmt.Errorf("deletion confirmation failed: %w", err) + } + } + + // Clean up DNS records + if !c.Opts.SkipDNSCleanup && baseDomain != "" && dnsZoneName != "" { + dnsProjectID := c.Opts.DNSProjectID + if dnsProjectID == "" { + dnsProjectID = infraEnv.DNSProjectID + } + if dnsProjectID == "" { + dnsProjectID = projectID + } + if err := deps.StepLogger.Step("Cleaning up DNS records", func() error { + return deps.GCPClient.DeleteDNSRecordSets(dnsProjectID, dnsZoneName, baseDomain) + }); err != nil { + log.Printf("Warning: failed to clean up DNS records: %v", err) + log.Printf("You may need to manually delete DNS records for %s in project %s", baseDomain, dnsProjectID) + } + } else if !c.Opts.SkipDNSCleanup && baseDomain == "" { + log.Printf("Skipping DNS cleanup: no base domain available (provide --base-domain or infra file, or use --skip-dns-cleanup)") + } + + // Delete the project + if err := deps.StepLogger.Step("Deleting GCP project", func() error { + return deps.GCPClient.DeleteProject(projectID) + }); err != nil { + return fmt.Errorf("failed to delete project: %w", err) + } + + // Clean up local infra file only if it matches the deleted project + if infraFileLoaded && infraEnv.ProjectID == projectID { + if err := deps.FileIO.Remove(deps.InfraFilePath); err != nil { + log.Printf("Warning: failed to remove local infra file: %v", err) + } else { + log.Printf("Removed local infra file: %s", deps.InfraFilePath) + } + } + + log.Println("\nGCP project cleanup completed successfully!") + log.Printf("Project '%s' has been scheduled for deletion.", projectID) + log.Printf("Note: GCP projects are retained for 30 days before permanent deletion. You can restore the project within this period from the GCP Console.") + + return nil +} + +func AddBootstrapGcpCleanupCmd(bootstrapGcp *cobra.Command, opts *GlobalOptions) { + cleanup := BootstrapGcpCleanupCmd{ + cmd: &cobra.Command{ + Use: "cleanup", + Short: "Clean up GCP infrastructure created by bootstrap-gcp", + Long: csio.Long(`Deletes a GCP project that was previously created using the bootstrap-gcp command.`), + Example: ` # Clean up using project ID from the local infra file + oms-cli beta bootstrap-gcp cleanup + + # Clean up a specific project + oms-cli beta bootstrap-gcp cleanup --project-id my-project-abc123 + + # Force cleanup without confirmation (skips OMS-managed check) + oms-cli beta bootstrap-gcp cleanup --project-id my-project-abc123 --force + + # Skip DNS record cleanup + oms-cli beta bootstrap-gcp cleanup --skip-dns-cleanup + + # Clean up with manual DNS settings (when infra file is not available) + oms-cli beta bootstrap-gcp cleanup --project-id my-project --base-domain example.com --dns-zone-name my-zone --dns-project-id dns-project`, + }, + Opts: &BootstrapGcpCleanupOpts{ + GlobalOptions: opts, + }, + } + + flags := cleanup.cmd.Flags() + flags.StringVar(&cleanup.Opts.ProjectID, "project-id", "", "GCP Project ID to delete (optional, will use infra file if not provided)") + flags.BoolVar(&cleanup.Opts.Force, "force", false, "Skip confirmation prompt and OMS-managed check") + flags.BoolVar(&cleanup.Opts.SkipDNSCleanup, "skip-dns-cleanup", false, "Skip cleaning up DNS records") + flags.StringVar(&cleanup.Opts.BaseDomain, "base-domain", "", "Base domain for DNS cleanup (optional, will use infra file if not provided)") + flags.StringVar(&cleanup.Opts.DNSZoneName, "dns-zone-name", "", "DNS zone name for DNS cleanup (optional, will use infra file if not provided)") + flags.StringVar(&cleanup.Opts.DNSProjectID, "dns-project-id", "", "GCP Project ID for DNS zone (optional, will use infra file if not provided)") + + cleanup.cmd.RunE = cleanup.RunE + bootstrapGcp.AddCommand(cleanup.cmd) +} diff --git a/cli/cmd/bootstrap_gcp_cleanup_test.go b/cli/cmd/bootstrap_gcp_cleanup_test.go new file mode 100644 index 00000000..62e786e6 --- /dev/null +++ b/cli/cmd/bootstrap_gcp_cleanup_test.go @@ -0,0 +1,395 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "bytes" + "encoding/json" + "errors" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + "github.com/codesphere-cloud/oms/cli/cmd" + "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/bootstrap/gcp" + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("BootstrapGcpCleanupCmd", func() { + var ( + opts *cmd.BootstrapGcpCleanupOpts + globalOpts *cmd.GlobalOptions + ) + + BeforeEach(func() { + globalOpts = &cmd.GlobalOptions{} + opts = &cmd.BootstrapGcpCleanupOpts{ + GlobalOptions: globalOpts, + ProjectID: "", + Force: false, + SkipDNSCleanup: false, + } + }) + + Describe("BootstrapGcpCleanupOpts structure", func() { + Context("when initialized", func() { + It("should have correct default values", func() { + Expect(opts.ProjectID).To(Equal("")) + Expect(opts.Force).To(BeFalse()) + Expect(opts.SkipDNSCleanup).To(BeFalse()) + Expect(opts.GlobalOptions).ToNot(BeNil()) + }) + }) + + Context("when flags are set", func() { + It("should store flag values correctly", func() { + opts.ProjectID = "test-project-123" + opts.Force = true + opts.SkipDNSCleanup = true + + Expect(opts.ProjectID).To(Equal("test-project-123")) + Expect(opts.Force).To(BeTrue()) + Expect(opts.SkipDNSCleanup).To(BeTrue()) + }) + }) + }) + + Describe("CodesphereEnvironment JSON marshaling", func() { + Context("when environment is complete", func() { + It("should marshal and unmarshal correctly", func() { + env := gcp.CodesphereEnvironment{ + ProjectID: "test-project", + BaseDomain: "example.com", + DNSZoneName: "test-zone", + DNSProjectID: "dns-project", + } + + data, err := json.Marshal(env) + Expect(err).NotTo(HaveOccurred()) + + var decoded gcp.CodesphereEnvironment + err = json.Unmarshal(data, &decoded) + Expect(err).NotTo(HaveOccurred()) + + Expect(decoded.ProjectID).To(Equal("test-project")) + Expect(decoded.BaseDomain).To(Equal("example.com")) + Expect(decoded.DNSZoneName).To(Equal("test-zone")) + Expect(decoded.DNSProjectID).To(Equal("dns-project")) + }) + }) + + Context("when environment is minimal", func() { + It("should handle missing DNS fields", func() { + env := gcp.CodesphereEnvironment{ + ProjectID: "test-project", + } + + data, err := json.Marshal(env) + Expect(err).NotTo(HaveOccurred()) + + var decoded gcp.CodesphereEnvironment + err = json.Unmarshal(data, &decoded) + Expect(err).NotTo(HaveOccurred()) + + Expect(decoded.ProjectID).To(Equal("test-project")) + Expect(decoded.BaseDomain).To(Equal("")) + Expect(decoded.DNSZoneName).To(Equal("")) + }) + }) + }) + + Describe("AddBootstrapGcpCleanupCmd", func() { + Context("when adding command", func() { + It("should not panic when adding to parent command", func() { + Expect(func() { + parentCmd := &cobra.Command{ + Use: "bootstrap-gcp", + } + cmd.AddBootstrapGcpCleanupCmd(parentCmd, globalOpts) + }).NotTo(Panic()) + }) + + It("should create command with correct flags", func() { + parentCmd := &cobra.Command{ + Use: "bootstrap-gcp", + } + cmd.AddBootstrapGcpCleanupCmd(parentCmd, globalOpts) + + // Verify the cleanup subcommand was added + cleanupCmd, _, err := parentCmd.Find([]string{"cleanup"}) + Expect(err).NotTo(HaveOccurred()) + Expect(cleanupCmd).NotTo(BeNil()) + Expect(cleanupCmd.Use).To(Equal("cleanup")) + + // Verify flags exist + projectIDFlag := cleanupCmd.Flags().Lookup("project-id") + Expect(projectIDFlag).NotTo(BeNil()) + + forceFlag := cleanupCmd.Flags().Lookup("force") + Expect(forceFlag).NotTo(BeNil()) + + skipDNSFlag := cleanupCmd.Flags().Lookup("skip-dns-cleanup") + Expect(skipDNSFlag).NotTo(BeNil()) + }) + }) + }) + + Describe("CleanupDeps structure", func() { + Context("when created", func() { + It("should hold all required dependencies", func() { + mockGCPClient := gcp.NewMockGCPClientManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + stlog := bootstrap.NewStepLogger(false) + confirmReader := bytes.NewBufferString("test-project\n") + + deps := &cmd.CleanupDeps{ + GCPClient: mockGCPClient, + FileIO: mockFileIO, + StepLogger: stlog, + ConfirmReader: confirmReader, + InfraFilePath: "/tmp/test-infra.json", + } + + Expect(deps.GCPClient).ToNot(BeNil()) + Expect(deps.FileIO).ToNot(BeNil()) + Expect(deps.StepLogger).ToNot(BeNil()) + Expect(deps.ConfirmReader).ToNot(BeNil()) + Expect(deps.InfraFilePath).To(Equal("/tmp/test-infra.json")) + }) + }) + }) + + Describe("executeCleanup", func() { + var ( + cleanupCmd *cmd.BootstrapGcpCleanupCmd + mockGCPClient *gcp.MockGCPClientManager + mockFileIO *util.MockFileIO + deps *cmd.CleanupDeps + ) + + BeforeEach(func() { + mockGCPClient = gcp.NewMockGCPClientManager(GinkgoT()) + mockFileIO = util.NewMockFileIO(GinkgoT()) + + cleanupCmd = &cmd.BootstrapGcpCleanupCmd{ + Opts: &cmd.BootstrapGcpCleanupOpts{ + GlobalOptions: globalOpts, + ProjectID: "", + Force: false, + SkipDNSCleanup: false, + }, + } + + deps = &cmd.CleanupDeps{ + GCPClient: mockGCPClient, + FileIO: mockFileIO, + StepLogger: bootstrap.NewStepLogger(false), + ConfirmReader: bytes.NewBufferString(""), + InfraFilePath: "/tmp/test-infra.json", + } + }) + + Context("when no project ID is provided and infra file doesn't exist", func() { + It("should return an error", func() { + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no project ID provided and no infra file found")) + }) + }) + + Context("when infra file exists but has empty project ID", func() { + It("should return an error about empty project ID", func() { + emptyEnv := gcp.CodesphereEnvironment{ + ProjectID: "", // Empty project ID + } + envData, _ := json.Marshal(emptyEnv) + + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(true) + mockFileIO.EXPECT().ReadFile("/tmp/test-infra.json").Return(envData, nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("contains empty project ID")) + }) + }) + + Context("when infra file exists with valid project ID", func() { + It("should load project ID from infra file and verify OMS management", func() { + validEnv := gcp.CodesphereEnvironment{ + ProjectID: "test-project-123", + } + envData, _ := json.Marshal(validEnv) + + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(true) + mockFileIO.EXPECT().ReadFile("/tmp/test-infra.json").Return(envData, nil) + mockGCPClient.EXPECT().IsOMSManagedProject("test-project-123").Return(false, nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("was not bootstrapped by OMS")) + }) + }) + + Context("when project ID is provided via flag", func() { + It("should use the provided project ID", func() { + cleanupCmd.Opts.ProjectID = "flag-project" + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) + mockGCPClient.EXPECT().IsOMSManagedProject("flag-project").Return(false, nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("flag-project was not bootstrapped by OMS")) + }) + }) + + Context("when OMS management check fails", func() { + It("should return the verification error", func() { + cleanupCmd.Opts.ProjectID = "test-project" + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) + mockGCPClient.EXPECT().IsOMSManagedProject("test-project").Return(false, errors.New("API error")) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to verify project")) + }) + }) + + Context("when force flag is set", func() { + It("should skip OMS management check and proceed to confirmation", func() { + cleanupCmd.Opts.ProjectID = "test-project" + cleanupCmd.Opts.Force = true + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) + mockGCPClient.EXPECT().DeleteProject("test-project").Return(nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when confirmation does not match", func() { + It("should abort the cleanup", func() { + cleanupCmd.Opts.ProjectID = "test-project" + deps.ConfirmReader = bytes.NewBufferString("wrong-input\n") + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) + mockGCPClient.EXPECT().IsOMSManagedProject("test-project").Return(true, nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("confirmation did not match project ID")) + }) + }) + + Context("when infra file read fails", func() { + It("should return the read error", func() { + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(true) + mockFileIO.EXPECT().ReadFile("/tmp/test-infra.json").Return(nil, os.ErrPermission) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to read gcp infra file")) + }) + }) + + Context("when infra file contains invalid JSON", func() { + It("should return the unmarshal error", func() { + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(true) + mockFileIO.EXPECT().ReadFile("/tmp/test-infra.json").Return([]byte("invalid-json"), nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to unmarshal gcp infra file")) + }) + }) + + Context("when DNS cleanup is enabled and infra has DNS info", func() { + It("should attempt DNS cleanup before deleting project", func() { + cleanupCmd.Opts.ProjectID = "test-project" + cleanupCmd.Opts.Force = true + + validEnv := gcp.CodesphereEnvironment{ + ProjectID: "test-project", + BaseDomain: "example.com", + DNSZoneName: "test-zone", + } + envData, _ := json.Marshal(validEnv) + + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(true) + mockFileIO.EXPECT().ReadFile("/tmp/test-infra.json").Return(envData, nil) + mockGCPClient.EXPECT().DeleteDNSRecordSets("test-project", "test-zone", "example.com").Return(nil) + mockGCPClient.EXPECT().DeleteProject("test-project").Return(nil) + mockFileIO.EXPECT().Remove("/tmp/test-infra.json").Return(nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when skip-dns-cleanup flag is set", func() { + It("should skip DNS cleanup and skip loading infra file when project-id is provided", func() { + cleanupCmd.Opts.ProjectID = "test-project" + cleanupCmd.Opts.Force = true + cleanupCmd.Opts.SkipDNSCleanup = true + + mockGCPClient.EXPECT().DeleteProject("test-project").Return(nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when infra file belongs to a different project", func() { + It("should skip DNS cleanup and not remove infra file", func() { + cleanupCmd.Opts.ProjectID = "other-project" + cleanupCmd.Opts.Force = true + + differentEnv := gcp.CodesphereEnvironment{ + ProjectID: "test-project", + BaseDomain: "example.com", + DNSZoneName: "test-zone", + } + envData, _ := json.Marshal(differentEnv) + + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(true) + mockFileIO.EXPECT().ReadFile("/tmp/test-infra.json").Return(envData, nil) + mockGCPClient.EXPECT().DeleteProject("other-project").Return(nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when infra file read fails with project ID provided", func() { + It("should continue with deletion but skip DNS cleanup", func() { + cleanupCmd.Opts.ProjectID = "test-project" + cleanupCmd.Opts.Force = true + + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(true) + mockFileIO.EXPECT().ReadFile("/tmp/test-infra.json").Return(nil, os.ErrPermission) + mockGCPClient.EXPECT().DeleteProject("test-project").Return(nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when infra file contains invalid JSON with project ID provided", func() { + It("should continue with deletion but skip DNS cleanup", func() { + cleanupCmd.Opts.ProjectID = "test-project" + cleanupCmd.Opts.Force = true + + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(true) + mockFileIO.EXPECT().ReadFile("/tmp/test-infra.json").Return([]byte("invalid-json"), nil) + mockGCPClient.EXPECT().DeleteProject("test-project").Return(nil) + + err := cleanupCmd.ExecuteCleanup(deps) + Expect(err).NotTo(HaveOccurred()) + }) + }) + }) +}) diff --git a/cli/cmd/bootstrap_gcp_postconfig.go b/cli/cmd/bootstrap_gcp_postconfig.go index 8e0072d9..148ff665 100644 --- a/cli/cmd/bootstrap_gcp_postconfig.go +++ b/cli/cmd/bootstrap_gcp_postconfig.go @@ -4,7 +4,6 @@ package cmd import ( - "encoding/json" "fmt" "log" @@ -32,18 +31,17 @@ func (c *BootstrapGcpPostconfigCmd) RunE(_ *cobra.Command, args []string) error log.Printf("running post-configuration steps...") icg := installer.NewInstallConfigManager() - fw := util.NewFilesystemWriter() - envFileContent, err := fw.ReadFile(gcp.GetInfraFilePath()) + infraFilePath := gcp.GetInfraFilePath() + codesphereEnv, exists, err := gcp.LoadInfraFile(fw, infraFilePath) if err != nil { - return fmt.Errorf("failed to read gcp infra file: %w", err) + return fmt.Errorf("failed to load gcp infra file: %w", err) } - - err = json.Unmarshal(envFileContent, &c.CodesphereEnv) - if err != nil { - return fmt.Errorf("failed to unmarshal gcp infra file: %w", err) + if !exists { + return fmt.Errorf("gcp infra file not found at %s", infraFilePath) } + c.CodesphereEnv = codesphereEnv err = icg.LoadInstallConfigFromFile(c.Opts.InstallConfigPath) if err != nil { diff --git a/docs/oms-cli_beta_bootstrap-gcp.md b/docs/oms-cli_beta_bootstrap-gcp.md index 47f1b596..34d2f9bc 100644 --- a/docs/oms-cli_beta_bootstrap-gcp.md +++ b/docs/oms-cli_beta_bootstrap-gcp.md @@ -56,5 +56,6 @@ oms-cli beta bootstrap-gcp [flags] ### SEE ALSO * [oms-cli beta](oms-cli_beta.md) - Commands for early testing +* [oms-cli beta bootstrap-gcp cleanup](oms-cli_beta_bootstrap-gcp_cleanup.md) - Clean up GCP infrastructure created by bootstrap-gcp * [oms-cli beta bootstrap-gcp postconfig](oms-cli_beta_bootstrap-gcp_postconfig.md) - Run post-configuration steps for GCP bootstrapping diff --git a/docs/oms-cli_beta_bootstrap-gcp_cleanup.md b/docs/oms-cli_beta_bootstrap-gcp_cleanup.md new file mode 100644 index 00000000..893238cc --- /dev/null +++ b/docs/oms-cli_beta_bootstrap-gcp_cleanup.md @@ -0,0 +1,47 @@ +## oms-cli beta bootstrap-gcp cleanup + +Clean up GCP infrastructure created by bootstrap-gcp + +### Synopsis + +Deletes a GCP project that was previously created using the bootstrap-gcp command. + +``` +oms-cli beta bootstrap-gcp cleanup [flags] +``` + +### Examples + +``` + # Clean up using project ID from the local infra file + oms-cli beta bootstrap-gcp cleanup + + # Clean up a specific project + oms-cli beta bootstrap-gcp cleanup --project-id my-project-abc123 + + # Force cleanup without confirmation (skips OMS-managed check) + oms-cli beta bootstrap-gcp cleanup --project-id my-project-abc123 --force + + # Skip DNS record cleanup + oms-cli beta bootstrap-gcp cleanup --skip-dns-cleanup + + # Clean up with manual DNS settings (when infra file is not available) + oms-cli beta bootstrap-gcp cleanup --project-id my-project --base-domain example.com --dns-zone-name my-zone --dns-project-id dns-project +``` + +### Options + +``` + --base-domain string Base domain for DNS cleanup (optional, will use infra file if not provided) + --dns-project-id string GCP Project ID for DNS zone (optional, will use infra file if not provided) + --dns-zone-name string DNS zone name for DNS cleanup (optional, will use infra file if not provided) + --force Skip confirmation prompt and OMS-managed check + -h, --help help for cleanup + --project-id string GCP Project ID to delete (optional, will use infra file if not provided) + --skip-dns-cleanup Skip cleaning up DNS records +``` + +### SEE ALSO + +* [oms-cli beta bootstrap-gcp](oms-cli_beta_bootstrap-gcp.md) - Bootstrap GCP infrastructure for Codesphere + diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index bbcc6ab8..61dedee6 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -5,6 +5,7 @@ package gcp import ( "context" + "encoding/json" "errors" "fmt" "slices" @@ -23,6 +24,7 @@ import ( "github.com/codesphere-cloud/oms/internal/util" "github.com/lithammer/shortuuid" "google.golang.org/api/dns/v1" + "google.golang.org/api/googleapi" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -35,6 +37,48 @@ const ( RegistryTypeGitHub RegistryType = "github" ) +// OMSManagedLabel is the label key used to identify projects created by OMS +const OMSManagedLabel = "oms-managed" + +// CheckOMSManagedLabel checks if the given labels map indicates an OMS-managed project. +// A project is considered OMS-managed if it has the 'oms-managed' label set to "true". +func CheckOMSManagedLabel(labels map[string]string) bool { + if labels == nil { + return false + } + value, exists := labels[OMSManagedLabel] + return exists && value == "true" +} + +// GetDNSRecordNames returns the DNS record names that OMS creates for a given base domain. +func GetDNSRecordNames(baseDomain string) []struct { + Name string + Rtype string +} { + return []struct { + Name string + Rtype string + }{ + {fmt.Sprintf("cs.%s.", baseDomain), "A"}, + {fmt.Sprintf("*.cs.%s.", baseDomain), "A"}, + {fmt.Sprintf("ws.%s.", baseDomain), "A"}, + {fmt.Sprintf("*.ws.%s.", baseDomain), "A"}, + } +} + +// IsNotFoundError checks if the error is a Google API "not found" error (HTTP 404). +func IsNotFoundError(err error) bool { + if err == nil { + return false + } + + var googleErr *googleapi.Error + if errors.As(err, &googleErr) { + return googleErr.Code == 404 + } + return false +} + type VMDef struct { Name string MachineType string @@ -159,6 +203,26 @@ func GetInfraFilePath() string { return fmt.Sprintf("%s/gcp-infra.json", workdir) } +// LoadInfraFile reads and parses the GCP infrastructure file from the specified path. +// Returns the environment, whether the file exists, and any error. +// If the file doesn't exist, returns an empty environment with exists=false and nil error. +func LoadInfraFile(fw util.FileIO, infraFilePath string) (CodesphereEnvironment, bool, error) { + if !fw.Exists(infraFilePath) { + return CodesphereEnvironment{}, false, nil + } + + content, err := fw.ReadFile(infraFilePath) + if err != nil { + return CodesphereEnvironment{}, true, fmt.Errorf("failed to read gcp infra file: %w", err) + } + + var env CodesphereEnvironment + if err := json.Unmarshal(content, &env); err != nil { + return CodesphereEnvironment{}, true, fmt.Errorf("failed to unmarshal gcp infra file: %w", err) + } + return env, true, nil +} + func (b *GCPBootstrapper) Bootstrap() error { if b.Env.InstallVersion != "" { err := b.stlog.Step("Validate input", b.ValidateInput) diff --git a/internal/bootstrap/gcp/gcp_client.go b/internal/bootstrap/gcp/gcp_client.go index b487343a..e4809ec0 100644 --- a/internal/bootstrap/gcp/gcp_client.go +++ b/internal/bootstrap/gcp/gcp_client.go @@ -36,6 +36,8 @@ type GCPClientManager interface { GetProjectByName(folderID string, displayName string) (*resourcemanagerpb.Project, error) CreateProjectID(projectName string) string CreateProject(parent, projectName, displayName string) (string, error) + DeleteProject(projectID string) error + IsOMSManagedProject(projectID string) (bool, error) GetBillingInfo(projectID string) (*cloudbilling.ProjectBillingInfo, error) EnableBilling(projectID, billingAccount string) error EnableAPIs(projectID string, apis []string) error @@ -44,6 +46,8 @@ type GCPClientManager interface { CreateServiceAccount(projectID, name, displayName string) (string, bool, error) CreateServiceAccountKey(projectID, saEmail string) (string, error) AssignIAMRole(projectID, saEmail string, saProjectID string, roles []string) error + GrantImpersonation(impersonatingServiceAccount, impersonatingProjectID, impersonatedServiceAccount, impersonatedProjectID string) error + RevokeImpersonation(impersonatingServiceAccount, impersonatingProjectID, impersonatedServiceAccount, impersonatedProjectID string) error CreateVPC(projectID, region, networkName, subnetName, routerName, natName string) error CreateFirewallRule(projectID string, rule *computepb.Firewall) error CreateInstance(projectID, zone string, instance *computepb.Instance) error @@ -52,6 +56,7 @@ type GCPClientManager interface { GetAddress(projectID, region, addressName string) (*computepb.Address, error) EnsureDNSManagedZone(projectID, zoneName, dnsName, description string) error EnsureDNSRecordSets(projectID, zoneName string, records []*dns.ResourceRecordSet) error + DeleteDNSRecordSets(projectID, zoneName, baseDomain string) error } // Concrete implementation @@ -110,6 +115,7 @@ func (c *GCPClient) CreateProjectID(projectName string) string { // CreateProject creates a new GCP project under the specified parent (folder or organization). // It returns the project ID of the newly created project. +// The project is labeled with 'oms-managed=true' to identify it as created by OMS. func (c *GCPClient) CreateProject(parent, projectID, displayName string) (string, error) { client, err := resourcemanager.NewProjectsClient(c.ctx) if err != nil { @@ -121,6 +127,9 @@ func (c *GCPClient) CreateProject(parent, projectID, displayName string) (string ProjectId: projectID, DisplayName: displayName, Parent: parent, + Labels: map[string]string{ + OMSManagedLabel: "true", + }, } op, err := client.CreateProject(c.ctx, &resourcemanagerpb.CreateProjectRequest{Project: project}) if err != nil { @@ -134,6 +143,46 @@ func (c *GCPClient) CreateProject(parent, projectID, displayName string) (string return resp.ProjectId, nil } +// DeleteProject deletes the specified GCP project. +func (c *GCPClient) DeleteProject(projectID string) error { + client, err := resourcemanager.NewProjectsClient(c.ctx) + if err != nil { + return fmt.Errorf("failed to create resource manager client: %w", err) + } + defer util.IgnoreError(client.Close) + + op, err := client.DeleteProject(c.ctx, &resourcemanagerpb.DeleteProjectRequest{ + Name: getProjectResourceName(projectID), + }) + if err != nil { + return fmt.Errorf("failed to initiate project deletion: %w", err) + } + + if _, err = op.Wait(c.ctx); err != nil { + return fmt.Errorf("failed to wait for project deletion: %w", err) + } + + return nil +} + +// IsOMSManagedProject checks if the given project was created by OMS by verifying the 'oms-managed' label. +func (c *GCPClient) IsOMSManagedProject(projectID string) (bool, error) { + client, err := resourcemanager.NewProjectsClient(c.ctx) + if err != nil { + return false, fmt.Errorf("failed to create resource manager client: %w", err) + } + defer util.IgnoreError(client.Close) + + project, err := client.GetProject(c.ctx, &resourcemanagerpb.GetProjectRequest{ + Name: getProjectResourceName(projectID), + }) + if err != nil { + return false, fmt.Errorf("failed to get project: %w", err) + } + + return CheckOMSManagedLabel(project.Labels), nil +} + func getProjectResourceName(projectID string) string { return fmt.Sprintf("projects/%s", projectID) } @@ -193,9 +242,11 @@ func (c *GCPClient) EnableAPIs(projectID string, apis []string) error { } if err != nil { errCh <- fmt.Errorf("failed to enable API %s: %w", api, err) + return } if _, err := op.Wait(c.ctx); err != nil { errCh <- fmt.Errorf("failed to enable API %s: %w", api, err) + return } c.st.Logf("API %s enabled", api) @@ -378,6 +429,136 @@ func (c *GCPClient) addRoleBindingToProject(member string, roles []string, resou return err } +// Types between ServiceAccount and Project IAM API differ, so we need a separate function +func (c *GCPClient) addRoleBindingToServiceAccount(member string, roles []string, resource string) error { + iamService, err := iam.NewService(c.ctx) + if err != nil { + return err + } + + // Get current policy + policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(resource).Context(c.ctx).Do() + if err != nil { + return fmt.Errorf("failed to get IAM policy for service account: %w", err) + } + + // Add role bindings directly to iam.Policy + updated := false + for _, role := range roles { + bindingExists := false + for _, binding := range policy.Bindings { + if binding.Role == role { + if !slices.Contains(binding.Members, member) { + binding.Members = append(binding.Members, member) + updated = true + } + bindingExists = true + break + } + } + if bindingExists { + continue + } + + // Assign role + policy.Bindings = append(policy.Bindings, &iam.Binding{ + Role: role, + Members: []string{member}, + }) + updated = true + } + + if !updated { + return nil + } + + // Set the updated policy + setReq := &iam.SetIamPolicyRequest{ + Policy: policy, + } + _, err = iamService.Projects.ServiceAccounts.SetIamPolicy(resource, setReq).Context(c.ctx).Do() + if err != nil { + return fmt.Errorf("failed to set IAM policy for service account: %w", err) + } + + return nil +} + +// GrantImpersonation grants the "roles/iam.serviceAccountTokenCreator" role to the impersonating service account on the impersonated service account, +// allowing the impersonating service account to generate access tokens for the impersonated service account, which is necessary for cross-project impersonation. +func (c *GCPClient) GrantImpersonation(impersonatingServiceAccount, impersonatingProjectID, impersonatedServiceAccount, impersonatedProjectID string) error { + impersonatingSAEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", impersonatingServiceAccount, impersonatingProjectID) + impersonatedSAEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", impersonatedServiceAccount, impersonatedProjectID) + + resourceName := fmt.Sprintf("projects/%s/serviceAccounts/%s", impersonatedProjectID, impersonatedSAEmail) + member := fmt.Sprintf("serviceAccount:%s", impersonatingSAEmail) + + return c.addRoleBindingToServiceAccount(member, []string{"roles/iam.serviceAccountTokenCreator"}, resourceName) +} + +// RevokeImpersonation revokes the "roles/iam.serviceAccountTokenCreator" role from the impersonating service account on the impersonated service account. +// This removes the cross-project impersonation permission that was previously granted. +func (c *GCPClient) RevokeImpersonation(impersonatingServiceAccount, impersonatingProjectID, impersonatedServiceAccount, impersonatedProjectID string) error { + impersonatingSAEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", impersonatingServiceAccount, impersonatingProjectID) + impersonatedSAEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", impersonatedServiceAccount, impersonatedProjectID) + + resourceName := fmt.Sprintf("projects/%s/serviceAccounts/%s", impersonatedProjectID, impersonatedSAEmail) + member := fmt.Sprintf("serviceAccount:%s", impersonatingSAEmail) + + return c.removeRoleBindingFromServiceAccount(member, []string{"roles/iam.serviceAccountTokenCreator"}, resourceName) +} + +// removeRoleBindingFromServiceAccount removes the specified role bindings for a member from a service account's IAM policy. +func (c *GCPClient) removeRoleBindingFromServiceAccount(member string, roles []string, resource string) error { + iamService, err := iam.NewService(c.ctx) + if err != nil { + return err + } + + policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(resource).Context(c.ctx).Do() + if err != nil { + if IsNotFoundError(err) { + return nil + } + return fmt.Errorf("failed to get IAM policy for service account: %w", err) + } + + updated := false + for _, role := range roles { + for i, binding := range policy.Bindings { + if binding.Role == role { + // Find and remove the member from this binding + for j, m := range binding.Members { + if m == member { + binding.Members = append(binding.Members[:j], binding.Members[j+1:]...) + updated = true + break + } + } + // If the binding has no more members, remove it entirely + if len(binding.Members) == 0 { + policy.Bindings = append(policy.Bindings[:i], policy.Bindings[i+1:]...) + } + break + } + } + } + + if !updated { + return nil + } + + setReq := &iam.SetIamPolicyRequest{ + Policy: policy, + } + _, err = iamService.Projects.ServiceAccounts.SetIamPolicy(resource, setReq).Context(c.ctx).Do() + if err != nil { + return fmt.Errorf("failed to set IAM policy for service account: %w", err) + } + + return nil +} + // CreateVPC creates a VPC network with the specified subnet, router, and NAT gateway. func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, routerName, natName string) error { // Create Network @@ -665,6 +846,35 @@ func (c *GCPClient) EnsureDNSRecordSets(projectID, zoneName string, records []*d return nil } +// DeleteDNSRecordSets deletes DNS record sets created by OMS for the given base domain. +func (c *GCPClient) DeleteDNSRecordSets(projectID, zoneName, baseDomain string) error { + service, err := dns.NewService(c.ctx) + if err != nil { + return fmt.Errorf("failed to create DNS service: %w", err) + } + + var deletions []*dns.ResourceRecordSet + for _, record := range GetDNSRecordNames(baseDomain) { + existing, err := service.ResourceRecordSets.Get(projectID, zoneName, record.Name, record.Rtype).Context(c.ctx).Do() + if IsNotFoundError(err) { + continue + } + if err != nil { + return fmt.Errorf("failed to get DNS record %s: %w", record.Name, err) + } + deletions = append(deletions, existing) + } + + if len(deletions) == 0 { + return nil + } + + if _, err = service.Changes.Create(projectID, zoneName, &dns.Change{Deletions: deletions}).Context(c.ctx).Do(); err != nil { + return fmt.Errorf("failed to delete DNS records: %w", err) + } + return nil +} + // Helper functions func protoString(s string) *string { return &s } func protoBool(b bool) *bool { return &b } diff --git a/internal/bootstrap/gcp/gcp_client_cleanup_test.go b/internal/bootstrap/gcp/gcp_client_cleanup_test.go new file mode 100644 index 00000000..5ecaef11 --- /dev/null +++ b/internal/bootstrap/gcp/gcp_client_cleanup_test.go @@ -0,0 +1,249 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp_test + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/api/googleapi" + + "github.com/codesphere-cloud/oms/internal/bootstrap/gcp" +) + +var _ = Describe("GCP Client Cleanup Methods", func() { + Describe("OMSManagedLabel constant", func() { + It("should be set to 'oms-managed'", func() { + Expect(gcp.OMSManagedLabel).To(Equal("oms-managed")) + }) + }) + + Describe("CheckOMSManagedLabel", func() { + Context("when labels contain oms-managed=true", func() { + It("should return true", func() { + labels := map[string]string{ + gcp.OMSManagedLabel: "true", + } + Expect(gcp.CheckOMSManagedLabel(labels)).To(BeTrue()) + }) + }) + + Context("when labels contain oms-managed=false", func() { + It("should return false", func() { + labels := map[string]string{ + gcp.OMSManagedLabel: "false", + } + Expect(gcp.CheckOMSManagedLabel(labels)).To(BeFalse()) + }) + }) + + Context("when labels do not contain oms-managed", func() { + It("should return false", func() { + labels := map[string]string{ + "other-label": "value", + } + Expect(gcp.CheckOMSManagedLabel(labels)).To(BeFalse()) + }) + }) + + Context("when labels map is nil", func() { + It("should return false", func() { + Expect(gcp.CheckOMSManagedLabel(nil)).To(BeFalse()) + }) + }) + + Context("when labels map is empty", func() { + It("should return false", func() { + labels := map[string]string{} + Expect(gcp.CheckOMSManagedLabel(labels)).To(BeFalse()) + }) + }) + + Context("when checking case sensitivity", func() { + It("should be case-sensitive for label values", func() { + testCases := []struct { + value string + expected bool + }{ + {"true", true}, + {"True", false}, + {"TRUE", false}, + {"1", false}, + {"yes", false}, + {"", false}, + } + + for _, tc := range testCases { + labels := map[string]string{ + gcp.OMSManagedLabel: tc.value, + } + Expect(gcp.CheckOMSManagedLabel(labels)).To(Equal(tc.expected), + fmt.Sprintf("Label value '%s' should result in %v", tc.value, tc.expected)) + } + }) + }) + + Context("when multiple labels exist", func() { + It("should correctly identify oms-managed among other labels", func() { + labels := map[string]string{ + gcp.OMSManagedLabel: "true", + "environment": "production", + "team": "platform", + "managed-by": "terraform", + } + Expect(gcp.CheckOMSManagedLabel(labels)).To(BeTrue()) + }) + }) + }) + + Describe("GetDNSRecordNames", func() { + Context("when given a simple base domain", func() { + It("should generate correct DNS record names", func() { + baseDomain := "example.com" + records := gcp.GetDNSRecordNames(baseDomain) + + Expect(records).To(HaveLen(4)) + Expect(records[0].Name).To(Equal("cs.example.com.")) + Expect(records[0].Rtype).To(Equal("A")) + Expect(records[1].Name).To(Equal("*.cs.example.com.")) + Expect(records[1].Rtype).To(Equal("A")) + Expect(records[2].Name).To(Equal("ws.example.com.")) + Expect(records[2].Rtype).To(Equal("A")) + Expect(records[3].Name).To(Equal("*.ws.example.com.")) + Expect(records[3].Rtype).To(Equal("A")) + }) + }) + + Context("when given a subdomain", func() { + It("should handle domains with subdomains correctly", func() { + baseDomain := "internal.codesphere.com" + records := gcp.GetDNSRecordNames(baseDomain) + + Expect(records).To(HaveLen(4)) + for _, record := range records { + Expect(record.Name).To(ContainSubstring("internal.codesphere.com")) + Expect(record.Name).To(HaveSuffix(".")) + Expect(record.Rtype).To(Equal("A")) + } + }) + }) + + Context("when ensuring all records are A type", func() { + It("should only generate A records", func() { + records := gcp.GetDNSRecordNames("test.com") + for _, record := range records { + Expect(record.Rtype).To(Equal("A")) + } + }) + }) + + Context("when ensuring trailing dot format", func() { + It("should append trailing dot for DNS FQDN format", func() { + records := gcp.GetDNSRecordNames("nodot.com") + for _, record := range records { + Expect(record.Name).To(HaveSuffix(".")) + } + }) + }) + }) + + Describe("IsNotFoundError", func() { + Context("when error is nil", func() { + It("should return false", func() { + Expect(gcp.IsNotFoundError(nil)).To(BeFalse()) + }) + }) + + Context("when error is a Google API 404 error", func() { + It("should return true", func() { + err := &googleapi.Error{ + Code: 404, + Message: "not found", + } + Expect(gcp.IsNotFoundError(err)).To(BeTrue()) + }) + }) + + Context("when error is a Google API non-404 error", func() { + It("should return false for 403 Forbidden", func() { + err := &googleapi.Error{ + Code: 403, + Message: "forbidden", + } + Expect(gcp.IsNotFoundError(err)).To(BeFalse()) + }) + + It("should return false for 500 Internal Server Error", func() { + err := &googleapi.Error{ + Code: 500, + Message: "internal error", + } + Expect(gcp.IsNotFoundError(err)).To(BeFalse()) + }) + + It("should return false for 401 Unauthorized", func() { + err := &googleapi.Error{ + Code: 401, + Message: "unauthorized", + } + Expect(gcp.IsNotFoundError(err)).To(BeFalse()) + }) + }) + + Context("when error is a non-Google API error", func() { + It("should return false", func() { + err := fmt.Errorf("some other error") + Expect(gcp.IsNotFoundError(err)).To(BeFalse()) + }) + }) + + Context("when error wraps a Google API 404 error", func() { + It("should return true for wrapped 404 errors", func() { + innerErr := &googleapi.Error{ + Code: 404, + Message: "not found", + } + wrappedErr := fmt.Errorf("failed to get record: %w", innerErr) + Expect(gcp.IsNotFoundError(wrappedErr)).To(BeTrue()) + }) + + It("should return false for wrapped non-404 errors", func() { + innerErr := &googleapi.Error{ + Code: 403, + Message: "forbidden", + } + wrappedErr := fmt.Errorf("failed to get record: %w", innerErr) + Expect(gcp.IsNotFoundError(wrappedErr)).To(BeFalse()) + }) + }) + }) + + Describe("RevokeImpersonation parameters", func() { + Context("when generating service account email format", func() { + It("should correctly format the service account email", func() { + impersonatingSA := "cloud-controller" + impersonatingProject := "test-project-123" + expectedEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", impersonatingSA, impersonatingProject) + Expect(expectedEmail).To(Equal("cloud-controller@test-project-123.iam.gserviceaccount.com")) + }) + + It("should correctly format the resource name for service account", func() { + impersonatedProject := "dns-project-456" + impersonatedSA := "dns-admin" + impersonatedSAEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", impersonatedSA, impersonatedProject) + expectedResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", impersonatedProject, impersonatedSAEmail) + Expect(expectedResource).To(Equal("projects/dns-project-456/serviceAccounts/dns-admin@dns-project-456.iam.gserviceaccount.com")) + }) + }) + + Context("when formatting the member string", func() { + It("should prefix with serviceAccount:", func() { + saEmail := "cloud-controller@test-project.iam.gserviceaccount.com" + member := fmt.Sprintf("serviceAccount:%s", saEmail) + Expect(member).To(Equal("serviceAccount:cloud-controller@test-project.iam.gserviceaccount.com")) + }) + }) + }) +}) diff --git a/internal/bootstrap/gcp/mocks.go b/internal/bootstrap/gcp/mocks.go index 495ad6e3..f385afa0 100644 --- a/internal/bootstrap/gcp/mocks.go +++ b/internal/bootstrap/gcp/mocks.go @@ -723,6 +723,120 @@ func (_c *MockGCPClientManager_CreateVPC_Call) RunAndReturn(run func(projectID s return _c } +// DeleteDNSRecordSets provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) DeleteDNSRecordSets(projectID string, zoneName string, baseDomain string) error { + ret := _mock.Called(projectID, zoneName, baseDomain) + + if len(ret) == 0 { + panic("no return value specified for DeleteDNSRecordSets") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = returnFunc(projectID, zoneName, baseDomain) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_DeleteDNSRecordSets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteDNSRecordSets' +type MockGCPClientManager_DeleteDNSRecordSets_Call struct { + *mock.Call +} + +// DeleteDNSRecordSets is a helper method to define mock.On call +// - projectID string +// - zoneName string +// - baseDomain string +func (_e *MockGCPClientManager_Expecter) DeleteDNSRecordSets(projectID interface{}, zoneName interface{}, baseDomain interface{}) *MockGCPClientManager_DeleteDNSRecordSets_Call { + return &MockGCPClientManager_DeleteDNSRecordSets_Call{Call: _e.mock.On("DeleteDNSRecordSets", projectID, zoneName, baseDomain)} +} + +func (_c *MockGCPClientManager_DeleteDNSRecordSets_Call) Run(run func(projectID string, zoneName string, baseDomain string)) *MockGCPClientManager_DeleteDNSRecordSets_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_DeleteDNSRecordSets_Call) Return(err error) *MockGCPClientManager_DeleteDNSRecordSets_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_DeleteDNSRecordSets_Call) RunAndReturn(run func(projectID string, zoneName string, baseDomain string) error) *MockGCPClientManager_DeleteDNSRecordSets_Call { + _c.Call.Return(run) + return _c +} + +// DeleteProject provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) DeleteProject(projectID string) error { + ret := _mock.Called(projectID) + + if len(ret) == 0 { + panic("no return value specified for DeleteProject") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(projectID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_DeleteProject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteProject' +type MockGCPClientManager_DeleteProject_Call struct { + *mock.Call +} + +// DeleteProject is a helper method to define mock.On call +// - projectID string +func (_e *MockGCPClientManager_Expecter) DeleteProject(projectID interface{}) *MockGCPClientManager_DeleteProject_Call { + return &MockGCPClientManager_DeleteProject_Call{Call: _e.mock.On("DeleteProject", projectID)} +} + +func (_c *MockGCPClientManager_DeleteProject_Call) Run(run func(projectID string)) *MockGCPClientManager_DeleteProject_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_DeleteProject_Call) Return(err error) *MockGCPClientManager_DeleteProject_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_DeleteProject_Call) RunAndReturn(run func(projectID string) error) *MockGCPClientManager_DeleteProject_Call { + _c.Call.Return(run) + return _c +} + // EnableAPIs provides a mock function for the type MockGCPClientManager func (_mock *MockGCPClientManager) EnableAPIs(projectID string, apis []string) error { ret := _mock.Called(projectID, apis) @@ -1320,3 +1434,201 @@ func (_c *MockGCPClientManager_GetProjectByName_Call) RunAndReturn(run func(fold _c.Call.Return(run) return _c } + +// GrantImpersonation provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) GrantImpersonation(impersonatingServiceAccount string, impersonatingProjectID string, impersonatedServiceAccount string, impersonatedProjectID string) error { + ret := _mock.Called(impersonatingServiceAccount, impersonatingProjectID, impersonatedServiceAccount, impersonatedProjectID) + + if len(ret) == 0 { + panic("no return value specified for GrantImpersonation") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string, string) error); ok { + r0 = returnFunc(impersonatingServiceAccount, impersonatingProjectID, impersonatedServiceAccount, impersonatedProjectID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_GrantImpersonation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GrantImpersonation' +type MockGCPClientManager_GrantImpersonation_Call struct { + *mock.Call +} + +// GrantImpersonation is a helper method to define mock.On call +// - impersonatingServiceAccount string +// - impersonatingProjectID string +// - impersonatedServiceAccount string +// - impersonatedProjectID string +func (_e *MockGCPClientManager_Expecter) GrantImpersonation(impersonatingServiceAccount interface{}, impersonatingProjectID interface{}, impersonatedServiceAccount interface{}, impersonatedProjectID interface{}) *MockGCPClientManager_GrantImpersonation_Call { + return &MockGCPClientManager_GrantImpersonation_Call{Call: _e.mock.On("GrantImpersonation", impersonatingServiceAccount, impersonatingProjectID, impersonatedServiceAccount, impersonatedProjectID)} +} + +func (_c *MockGCPClientManager_GrantImpersonation_Call) Run(run func(impersonatingServiceAccount string, impersonatingProjectID string, impersonatedServiceAccount string, impersonatedProjectID string)) *MockGCPClientManager_GrantImpersonation_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_GrantImpersonation_Call) Return(err error) *MockGCPClientManager_GrantImpersonation_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_GrantImpersonation_Call) RunAndReturn(run func(impersonatingServiceAccount string, impersonatingProjectID string, impersonatedServiceAccount string, impersonatedProjectID string) error) *MockGCPClientManager_GrantImpersonation_Call { + _c.Call.Return(run) + return _c +} + +// IsOMSManagedProject provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) IsOMSManagedProject(projectID string) (bool, error) { + ret := _mock.Called(projectID) + + if len(ret) == 0 { + panic("no return value specified for IsOMSManagedProject") + } + + var r0 bool + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok { + return returnFunc(projectID) + } + if returnFunc, ok := ret.Get(0).(func(string) bool); ok { + r0 = returnFunc(projectID) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(projectID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGCPClientManager_IsOMSManagedProject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsOMSManagedProject' +type MockGCPClientManager_IsOMSManagedProject_Call struct { + *mock.Call +} + +// IsOMSManagedProject is a helper method to define mock.On call +// - projectID string +func (_e *MockGCPClientManager_Expecter) IsOMSManagedProject(projectID interface{}) *MockGCPClientManager_IsOMSManagedProject_Call { + return &MockGCPClientManager_IsOMSManagedProject_Call{Call: _e.mock.On("IsOMSManagedProject", projectID)} +} + +func (_c *MockGCPClientManager_IsOMSManagedProject_Call) Run(run func(projectID string)) *MockGCPClientManager_IsOMSManagedProject_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_IsOMSManagedProject_Call) Return(b bool, err error) *MockGCPClientManager_IsOMSManagedProject_Call { + _c.Call.Return(b, err) + return _c +} + +func (_c *MockGCPClientManager_IsOMSManagedProject_Call) RunAndReturn(run func(projectID string) (bool, error)) *MockGCPClientManager_IsOMSManagedProject_Call { + _c.Call.Return(run) + return _c +} + +// RevokeImpersonation provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) RevokeImpersonation(impersonatingServiceAccount string, impersonatingProjectID string, impersonatedServiceAccount string, impersonatedProjectID string) error { + ret := _mock.Called(impersonatingServiceAccount, impersonatingProjectID, impersonatedServiceAccount, impersonatedProjectID) + + if len(ret) == 0 { + panic("no return value specified for RevokeImpersonation") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string, string) error); ok { + r0 = returnFunc(impersonatingServiceAccount, impersonatingProjectID, impersonatedServiceAccount, impersonatedProjectID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_RevokeImpersonation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RevokeImpersonation' +type MockGCPClientManager_RevokeImpersonation_Call struct { + *mock.Call +} + +// RevokeImpersonation is a helper method to define mock.On call +// - impersonatingServiceAccount string +// - impersonatingProjectID string +// - impersonatedServiceAccount string +// - impersonatedProjectID string +func (_e *MockGCPClientManager_Expecter) RevokeImpersonation(impersonatingServiceAccount interface{}, impersonatingProjectID interface{}, impersonatedServiceAccount interface{}, impersonatedProjectID interface{}) *MockGCPClientManager_RevokeImpersonation_Call { + return &MockGCPClientManager_RevokeImpersonation_Call{Call: _e.mock.On("RevokeImpersonation", impersonatingServiceAccount, impersonatingProjectID, impersonatedServiceAccount, impersonatedProjectID)} +} + +func (_c *MockGCPClientManager_RevokeImpersonation_Call) Run(run func(impersonatingServiceAccount string, impersonatingProjectID string, impersonatedServiceAccount string, impersonatedProjectID string)) *MockGCPClientManager_RevokeImpersonation_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_RevokeImpersonation_Call) Return(err error) *MockGCPClientManager_RevokeImpersonation_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_RevokeImpersonation_Call) RunAndReturn(run func(impersonatingServiceAccount string, impersonatingProjectID string, impersonatedServiceAccount string, impersonatedProjectID string) error) *MockGCPClientManager_RevokeImpersonation_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/util/filewriter.go b/internal/util/filewriter.go index 88722823..b8956d77 100644 --- a/internal/util/filewriter.go +++ b/internal/util/filewriter.go @@ -21,6 +21,7 @@ type FileIO interface { ReadDir(dirname string) ([]os.DirEntry, error) ReadFile(filename string) ([]byte, error) CreateAndWrite(filePath string, data []byte, fileType string) error + Remove(path string) error } type FilesystemWriter struct{} @@ -93,6 +94,10 @@ func (fs *FilesystemWriter) ReadFile(filename string) ([]byte, error) { return os.ReadFile(filename) } +func (fs *FilesystemWriter) Remove(path string) error { + return os.Remove(path) +} + type ClosableFile interface { Close() error } diff --git a/internal/util/mocks.go b/internal/util/mocks.go index abfdd4f8..4977f6ec 100644 --- a/internal/util/mocks.go +++ b/internal/util/mocks.go @@ -746,6 +746,57 @@ func (_c *MockFileIO_ReadFile_Call) RunAndReturn(run func(filename string) ([]by return _c } +// Remove provides a mock function for the type MockFileIO +func (_mock *MockFileIO) Remove(path string) error { + ret := _mock.Called(path) + + if len(ret) == 0 { + panic("no return value specified for Remove") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(path) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockFileIO_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove' +type MockFileIO_Remove_Call struct { + *mock.Call +} + +// Remove is a helper method to define mock.On call +// - path string +func (_e *MockFileIO_Expecter) Remove(path interface{}) *MockFileIO_Remove_Call { + return &MockFileIO_Remove_Call{Call: _e.mock.On("Remove", path)} +} + +func (_c *MockFileIO_Remove_Call) Run(run func(path string)) *MockFileIO_Remove_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockFileIO_Remove_Call) Return(err error) *MockFileIO_Remove_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockFileIO_Remove_Call) RunAndReturn(run func(path string) error) *MockFileIO_Remove_Call { + _c.Call.Return(run) + return _c +} + // WriteFile provides a mock function for the type MockFileIO func (_mock *MockFileIO) WriteFile(filename string, data []byte, perm os.FileMode) error { ret := _mock.Called(filename, data, perm)