diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index c1d4e507..4cda4889 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -99,6 +99,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..47bb96af --- /dev/null +++ b/cli/cmd/bootstrap_gcp_cleanup.go @@ -0,0 +1,306 @@ +// 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 +} + +// cleanupExecutor manages state and logic for each cleanup step. +type cleanupExecutor struct { + opts *BootstrapGcpCleanupOpts + deps *CleanupDeps + projectID string + infraEnv gcp.CodesphereEnvironment + infraFileLoaded bool + baseDomain string + dnsZoneName string + dnsProjectID 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) +} + +// ExecuteCleanup performs the cleanup operation with the provided dependencies. +func (c *BootstrapGcpCleanupCmd) ExecuteCleanup(deps *CleanupDeps) error { + exec, err := newCleanupExecutor(c.Opts, deps) + if err != nil { + return fmt.Errorf("failed to resolve cleanup configuration: %w", err) + } + + if err := exec.verifyAndConfirm(); err != nil { + return err + } + + if err := exec.cleanupDNSRecords(); err != nil { + log.Printf("Warning: DNS cleanup failed: %v", err) + log.Printf("You may need to manually delete DNS records for %s in project %s", exec.baseDomain, exec.dnsProjectID) + } + + if err := exec.removeDNSIAMBinding(); err != nil { + log.Printf("Warning: failed to remove cloud-controller IAM binding from DNS project %s: %v", exec.dnsProjectID, err) + log.Printf("You may need to manually remove the serviceAccount:cloud-controller@%s.iam.gserviceaccount.com binding from project %s", exec.projectID, exec.dnsProjectID) + } + + if err := exec.deleteProject(); err != nil { + return fmt.Errorf("failed to delete project: %w", err) + } + + exec.removeLocalInfraFile() + + log.Println("\nGCP project cleanup completed successfully!") + log.Printf("Project '%s' has been scheduled for deletion.", exec.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 +} + +// newCleanupExecutor resolves configuration from flags and the infra file, +// returning an executor ready to run the cleanup steps. +func newCleanupExecutor(opts *BootstrapGcpCleanupOpts, deps *CleanupDeps) (*cleanupExecutor, error) { + exec := &cleanupExecutor{ + opts: opts, + deps: deps, + projectID: opts.ProjectID, + } + if err := exec.loadInfraFileIfNeeded(); err != nil { + return nil, err + } + if err := exec.resolveProjectID(); err != nil { + return nil, err + } + exec.resolveDNSSettings() + return exec, nil +} + +// loadInfraFileIfNeeded loads the infra file when the project ID or DNS project. +func (e *cleanupExecutor) loadInfraFileIfNeeded() error { + missingDNSProjectID := e.opts.DNSProjectID == "" + missingDNSInfo := missingDNSProjectID + if !e.opts.SkipDNSCleanup { + missingDNSInfo = missingDNSProjectID || e.opts.BaseDomain == "" || e.opts.DNSZoneName == "" + } + if e.projectID != "" && !missingDNSInfo { + return nil + } + + infraEnv, infraFileExists, err := gcp.LoadInfraFile(e.deps.FileIO, e.deps.InfraFilePath) + if err != nil { + if e.projectID == "" { + return fmt.Errorf("failed to load infra file: %w", err) + } + log.Printf("Warning: %v", err) + return nil + } + + if infraEnv.ProjectID != "" { + e.infraEnv = infraEnv + e.infraFileLoaded = true + return nil + } + + if infraFileExists && e.projectID == "" { + return fmt.Errorf("infra file at %s contains empty project ID", e.deps.InfraFilePath) + } + + return nil +} + +// resolveProjectID determines the project ID from the flag or the infra file +func (e *cleanupExecutor) resolveProjectID() error { + if e.projectID != "" { + if e.infraFileLoaded && e.infraEnv.ProjectID != e.projectID { + log.Printf("Warning: infra file contains project ID '%s' but deleting '%s'; ignoring infra file for DNS cleanup", e.infraEnv.ProjectID, e.projectID) + e.infraEnv = gcp.CodesphereEnvironment{} + e.infraFileLoaded = false + } + return nil + } + + if e.infraEnv.ProjectID == "" { + return fmt.Errorf("no project ID provided and no infra file found at %s", e.deps.InfraFilePath) + } + + e.projectID = e.infraEnv.ProjectID + log.Printf("Using project ID from infra file: %s", e.projectID) + return nil +} + +// resolveDNSSettings resolves DNS configuration from flags with infra file fallback. +func (e *cleanupExecutor) resolveDNSSettings() { + e.baseDomain = e.opts.BaseDomain + if e.baseDomain == "" { + e.baseDomain = e.infraEnv.BaseDomain + } + e.dnsZoneName = e.opts.DNSZoneName + if e.dnsZoneName == "" { + e.dnsZoneName = e.infraEnv.DNSZoneName + } + e.dnsProjectID = e.opts.DNSProjectID + if e.dnsProjectID == "" { + e.dnsProjectID = e.infraEnv.DNSProjectID + } + if e.dnsProjectID == "" { + e.dnsProjectID = e.projectID + } +} + +// verifyAndConfirm checks that the project is OMS-managed and prompts the user +// for deletion confirmation, unless --force is set. +func (e *cleanupExecutor) verifyAndConfirm() error { + if e.opts.Force { + log.Printf("Skipping OMS-managed verification and deletion confirmation (--force flag used)") + return nil + } + + isOMSManaged, err := e.deps.GCPClient.IsOMSManagedProject(e.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", e.projectID) + } + + return e.confirmDeletion() +} + +func (e *cleanupExecutor) confirmDeletion() error { + log.Printf("WARNING: This will permanently delete the GCP project '%s' and all its resources.", e.projectID) + log.Printf("This action cannot be undone.\n") + log.Println("Type the project ID to confirm deletion: ") + + reader := bufio.NewReader(e.deps.ConfirmReader) + confirmation, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } + if strings.TrimSpace(confirmation) != e.projectID { + return fmt.Errorf("confirmation did not match project ID, aborting cleanup") + } + return nil +} + +// cleanupDNSRecords deletes OMS-created DNS records if DNS cleanup is enabled +// and the required DNS information is available. +func (e *cleanupExecutor) cleanupDNSRecords() error { + if e.opts.SkipDNSCleanup { + return nil + } + if e.baseDomain == "" || e.dnsZoneName == "" { + log.Printf("Skipping DNS cleanup: missing base domain or DNS zone name (provide --base-domain/--dns-zone-name or use --skip-dns-cleanup)") + return nil + } + return e.deps.StepLogger.Step("Clean up DNS records", func() error { + return e.deps.GCPClient.DeleteDNSRecordSets(e.dnsProjectID, e.dnsZoneName, e.baseDomain) + }) +} + +// removeDNSIAMBinding removes the cloud-controller service account's IAM binding +// from the DNS project. This is independent of --skip-dns-cleanup. +func (e *cleanupExecutor) removeDNSIAMBinding() error { + if e.dnsProjectID == "" || e.dnsProjectID == e.projectID { + return nil + } + return e.deps.StepLogger.Step("Remove DNS service account IAM binding", func() error { + return e.deps.GCPClient.RemoveIAMRoleBinding(e.dnsProjectID, "cloud-controller", e.projectID, []string{"roles/dns.admin"}) + }) +} + +// deleteProject deletes the GCP project. +func (e *cleanupExecutor) deleteProject() error { + return e.deps.StepLogger.Step("Delete GCP project", func() error { + return e.deps.GCPClient.DeleteProject(e.projectID) + }) +} + +// removeLocalInfraFile removes the local infra file if it matches the deleted project. +func (e *cleanupExecutor) removeLocalInfraFile() { + if !e.infraFileLoaded || e.infraEnv.ProjectID != e.projectID { + return + } + if err := e.deps.FileIO.Remove(e.deps.InfraFilePath); err != nil { + log.Printf("Warning: failed to remove local infra file: %v", err) + } else { + log.Printf("Removed local infra file: %s", e.deps.InfraFilePath) + } +} + +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: formatExamples("beta bootstrap-gcp cleanup", []csio.Example{ + {Desc: "Clean up using project ID from the local infra file"}, + {Cmd: "--project-id my-project-abc123", Desc: "Clean up a specific project"}, + {Cmd: "--project-id my-project-abc123 --force", Desc: "Force cleanup without confirmation (skips OMS-managed check)"}, + {Cmd: "--skip-dns-cleanup", Desc: "Skip DNS record cleanup"}, + {Cmd: "--project-id my-project --base-domain example.com --dns-zone-name my-zone --dns-project-id dns-project", Desc: "Clean up with manual DNS settings (when infra file is not available)"}, + }), + }, + 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..5caed5f7 --- /dev/null +++ b/cli/cmd/bootstrap_gcp_cleanup_test.go @@ -0,0 +1,396 @@ +// 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 record cleanup but still delete the project", func() { + cleanupCmd.Opts.ProjectID = "test-project" + cleanupCmd.Opts.Force = true + cleanupCmd.Opts.SkipDNSCleanup = 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 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_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index 33c53b0b..fd5ca56d 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -57,5 +57,6 @@ oms beta bootstrap-gcp [flags] ### SEE ALSO * [oms beta](oms_beta.md) - Commands for early testing +* [oms beta bootstrap-gcp cleanup](oms_beta_bootstrap-gcp_cleanup.md) - Clean up GCP infrastructure created by bootstrap-gcp * [oms beta bootstrap-gcp postconfig](oms_beta_bootstrap-gcp_postconfig.md) - Run post-configuration steps for GCP bootstrapping diff --git a/docs/oms_beta_bootstrap-gcp_cleanup.md b/docs/oms_beta_bootstrap-gcp_cleanup.md new file mode 100644 index 00000000..09d87022 --- /dev/null +++ b/docs/oms_beta_bootstrap-gcp_cleanup.md @@ -0,0 +1,48 @@ +## oms 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 beta bootstrap-gcp cleanup [flags] +``` + +### Examples + +``` +# Clean up using project ID from the local infra file +$ oms beta bootstrap-gcp cleanup + +# Clean up a specific project +$ oms beta bootstrap-gcp cleanup --project-id my-project-abc123 + +# Force cleanup without confirmation (skips OMS-managed check) +$ oms beta bootstrap-gcp cleanup --project-id my-project-abc123 --force + +# Skip DNS record cleanup +$ oms beta bootstrap-gcp cleanup --skip-dns-cleanup + +# Clean up with manual DNS settings (when infra file is not available) +$ oms 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 beta bootstrap-gcp](oms_beta_bootstrap-gcp.md) - Bootstrap GCP infrastructure for Codesphere + diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 49de45ca..774781b7 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 @@ -160,6 +204,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 { err := b.stlog.Step("Validate input", b.ValidateInput) if err != nil { diff --git a/internal/bootstrap/gcp/gcp_client.go b/internal/bootstrap/gcp/gcp_client.go index b487343a..dda6af89 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,7 @@ 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 + RemoveIAMRoleBinding(projectID, saName string, saProjectID string, roles []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 +55,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 +114,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 +126,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 +142,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 +241,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 +428,54 @@ func (c *GCPClient) addRoleBindingToProject(member string, roles []string, resou return err } +// RemoveIAMRoleBinding removes the specified IAM role bindings for a service account from a project. +func (c *GCPClient) RemoveIAMRoleBinding(projectID, saName string, saProjectID string, roles []string) error { + saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", saName, saProjectID) + member := fmt.Sprintf("serviceAccount:%s", saEmail) + resource := fmt.Sprintf("projects/%s", projectID) + return c.removeRoleBindingFromProject(member, roles, resource) +} + +func (c *GCPClient) removeRoleBindingFromProject(member string, roles []string, resource string) error { + client, err := resourcemanager.NewProjectsClient(c.ctx) + if err != nil { + return err + } + defer util.IgnoreError(client.Close) + + policy, err := client.GetIamPolicy(c.ctx, &iampb.GetIamPolicyRequest{Resource: resource}) + if err != nil { + return err + } + + updated := false + for _, role := range roles { + for i, binding := range policy.Bindings { + if binding.Role != role { + continue + } + before := len(binding.Members) + policy.Bindings[i].Members = slices.DeleteFunc(binding.Members, func(m string) bool { + return m == member + }) + if len(policy.Bindings[i].Members) != before { + updated = true + } + break + } + } + + if !updated { + return nil + } + + _, err = client.SetIamPolicy(c.ctx, &iampb.SetIamPolicyRequest{ + Resource: resource, + Policy: policy, + }) + return err +} + // 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 +763,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..68b8cd85 --- /dev/null +++ b/internal/bootstrap/gcp/gcp_client_cleanup_test.go @@ -0,0 +1,223 @@ +// 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()) + }) + }) + }) + +}) diff --git a/internal/bootstrap/gcp/mocks.go b/internal/bootstrap/gcp/mocks.go index 495ad6e3..f9e49a96 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,132 @@ func (_c *MockGCPClientManager_GetProjectByName_Call) RunAndReturn(run func(fold _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 +} + +// RemoveIAMRoleBinding provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) RemoveIAMRoleBinding(projectID string, saName string, saProjectID string, roles []string) error { + ret := _mock.Called(projectID, saName, saProjectID, roles) + + if len(ret) == 0 { + panic("no return value specified for RemoveIAMRoleBinding") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string, []string) error); ok { + r0 = returnFunc(projectID, saName, saProjectID, roles) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_RemoveIAMRoleBinding_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveIAMRoleBinding' +type MockGCPClientManager_RemoveIAMRoleBinding_Call struct { + *mock.Call +} + +// RemoveIAMRoleBinding is a helper method to define mock.On call +// - projectID string +// - saName string +// - saProjectID string +// - roles []string +func (_e *MockGCPClientManager_Expecter) RemoveIAMRoleBinding(projectID interface{}, saName interface{}, saProjectID interface{}, roles interface{}) *MockGCPClientManager_RemoveIAMRoleBinding_Call { + return &MockGCPClientManager_RemoveIAMRoleBinding_Call{Call: _e.mock.On("RemoveIAMRoleBinding", projectID, saName, saProjectID, roles)} +} + +func (_c *MockGCPClientManager_RemoveIAMRoleBinding_Call) Run(run func(projectID string, saName string, saProjectID string, roles []string)) *MockGCPClientManager_RemoveIAMRoleBinding_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_RemoveIAMRoleBinding_Call) Return(err error) *MockGCPClientManager_RemoveIAMRoleBinding_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_RemoveIAMRoleBinding_Call) RunAndReturn(run func(projectID string, saName string, saProjectID string, roles []string) error) *MockGCPClientManager_RemoveIAMRoleBinding_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)