From def841532b6ffa72bd0d04bbafeaa3b78a21f6eb Mon Sep 17 00:00:00 2001 From: eding Date: Wed, 9 Sep 2026 20:59:03 +0200 Subject: [PATCH] feat: purge cluster CSI storage on rebuild --- cmd/bootstrap.go | 4 +- internal/bootstrap/bootstrap.go | 9 ++++ internal/bootstrap/democratic_csi.go | 66 +++++++++++++++++++++++ internal/bootstrap/democratic_csi_test.go | 33 ++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 internal/bootstrap/democratic_csi.go create mode 100644 internal/bootstrap/democratic_csi_test.go diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 478ba6f..8f36761 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -26,6 +26,7 @@ var bootstrapRotateWebhookAuthorization bool var bootstrapMergeBootstrapPR bool var bootstrapManageNetworkBridges bool var bootstrapEnableDelivery bool +var bootstrapDestroyDemocraticCSIStorage bool var upsertOperationalSecret = bootstrap.UpsertOperationalSecret var bootstrapCmd = &cobra.Command{ @@ -51,6 +52,7 @@ func init() { bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping") bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges") bootstrapCmd.Flags().BoolVar(&bootstrapEnableDelivery, "enable-delivery", false, "Resolve delivery defaults and reconcile the configured app delivery source") + bootstrapCmd.Flags().BoolVar(&bootstrapDestroyDemocraticCSIStorage, "destroy-democratic-csi-storage", false, "Delete only TrueNAS datasets under this cluster's configured Democratic CSI parent during rebuild") } func runBootstrap(cmd *cobra.Command, args []string) error { @@ -191,7 +193,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error { } } - runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery} + runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery, DestroyDemocraticCSIStorage: bootstrapDestroyDemocraticCSIStorage} return runner.Run() } diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index d733e2d..3b4f129 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -47,6 +47,7 @@ type Runner struct { EnableDelivery bool SkipDeliveryScaffolding bool AutoMergeBootstrapMigration bool + DestroyDemocraticCSIStorage bool } type operationalSecrets struct { @@ -186,6 +187,9 @@ func (r Runner) Run() error { if r.Mode, err = resolveLifecycleMode(r.Mode, r.ConfirmRebuild); err != nil { return err } + if r.DestroyDemocraticCSIStorage && r.Mode != Rebuild { + return errors.New("--destroy-democratic-csi-storage requires --mode=rebuild --yes") + } if r.RegisterWebhook { return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir)) } @@ -294,6 +298,11 @@ func (r Runner) Run() error { } if r.Config.Talos.AutoRunTerraform { + if r.DestroyDemocraticCSIStorage { + if err := destroyDemocraticCSIStorage(r.Config.DemocraticCSI); err != nil { + return fmt.Errorf("destroy Democratic CSI storage: %w", err) + } + } if err := r.reconcileTerraform(terraformDir); err != nil { return err } diff --git a/internal/bootstrap/democratic_csi.go b/internal/bootstrap/democratic_csi.go new file mode 100644 index 0000000..1df2c4d --- /dev/null +++ b/internal/bootstrap/democratic_csi.go @@ -0,0 +1,66 @@ +package bootstrap + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" +) + +var democraticCSIHTTPClient = &http.Client{Timeout: 30 * time.Second} + +func destroyDemocraticCSIStorage(csi config.DemocraticCSIConfig) error { + parent := strings.Trim(csi.DatasetParentNFS, "/") + if parent == "" || strings.Contains(parent, "..") { + return fmt.Errorf("invalid Democratic CSI dataset parent") + } + base := "http://" + strings.TrimPrefix(strings.TrimPrefix(csi.TrueNASHost, "http://"), "https://") + ":80/api/v2.0/pool/dataset" + request, err := http.NewRequest(http.MethodGet, base+"?parent="+url.QueryEscape(parent), nil) + if err != nil { + return err + } + request.Header.Set("Authorization", "Bearer "+csi.TrueNASAPIKey) + response, err := democraticCSIHTTPClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("list datasets: %s", response.Status) + } + var datasets []struct { + Name string `json:"name"` + } + if err := json.NewDecoder(response.Body).Decode(&datasets); err != nil { + return err + } + prefix := parent + "/" + children := make([]string, 0, len(datasets)) + for _, dataset := range datasets { + if strings.HasPrefix(dataset.Name, prefix) && !strings.Contains(strings.TrimPrefix(dataset.Name, prefix), "/") { + children = append(children, dataset.Name) + } + } + sort.Strings(children) + for _, child := range children { + request, err := http.NewRequest(http.MethodDelete, base+"/id/"+url.PathEscape(child)+"?recursive=true&force=true", nil) + if err != nil { + return err + } + request.Header.Set("Authorization", "Bearer "+csi.TrueNASAPIKey) + response, err := democraticCSIHTTPClient.Do(request) + if err != nil { + return err + } + response.Body.Close() + if response.StatusCode != http.StatusNoContent { + return fmt.Errorf("delete dataset %q: %s", child, response.Status) + } + } + return nil +} diff --git a/internal/bootstrap/democratic_csi_test.go b/internal/bootstrap/democratic_csi_test.go new file mode 100644 index 0000000..d72e7fb --- /dev/null +++ b/internal/bootstrap/democratic_csi_test.go @@ -0,0 +1,33 @@ +package bootstrap + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" +) + +type roundTripper func(*http.Request) (*http.Response, error) + +func (f roundTripper) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +func TestDestroyDemocraticCSIStorageDeletesOnlyDirectChildren(t *testing.T) { + original := democraticCSIHTTPClient + t.Cleanup(func() { democraticCSIHTTPClient = original }) + var deleted []string + democraticCSIHTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) { + if request.Method == http.MethodGet { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`[{"name":"pool/cluster/one"},{"name":"pool/cluster/one/child"},{"name":"pool/other"}]`))}, nil + } + deleted = append(deleted, request.URL.EscapedPath()) + return &http.Response{StatusCode: http.StatusNoContent, Body: io.NopCloser(strings.NewReader(""))}, nil + })} + if err := destroyDemocraticCSIStorage(config.DemocraticCSIConfig{TrueNASHost: "truenas.test", TrueNASAPIKey: "token", DatasetParentNFS: "pool/cluster"}); err != nil { + t.Fatal(err) + } + if len(deleted) != 1 || !strings.Contains(deleted[0], "pool%2Fcluster%2Fone") { + t.Fatalf("deleted %v", deleted) + } +}