feat: purge cluster CSI storage on rebuild #37

Merged
eding merged 1 commit from feat/rebuild-csi-storage into main 2026-09-09 21:02:15 +02:00
4 changed files with 111 additions and 1 deletions

View file

@ -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()
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}