From e089fe01018ef72ebb3194a18efd05f2c2dca5c5 Mon Sep 17 00:00:00 2001 From: jason yang Date: Mon, 14 Jul 2025 04:33:58 +0000 Subject: [PATCH] add overlay file deletion support Signed-off-by: jason yang --- CHANGELOG.md | 1 + internal/pkg/overlay/config.go | 4 +- internal/pkg/overlay/overlay.go | 82 +++++++++++++++++++--- internal/pkg/warewulfd/api/api.go | 1 + internal/pkg/warewulfd/api/overlay.go | 32 +++++++++ internal/pkg/warewulfd/api/overlay_test.go | 14 ++++ 6 files changed, 122 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce5c6b7b..5ad616c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Added system and runtime overlay built time to REST API. - Support If-None-Match header in `PUT /api/nodes/{id}` +- Added `DELETE /api/overlays/{name}/file?path={path}` ### Changed diff --git a/internal/pkg/overlay/config.go b/internal/pkg/overlay/config.go index 73f0c8de..f48ee64f 100644 --- a/internal/pkg/overlay/config.go +++ b/internal/pkg/overlay/config.go @@ -54,7 +54,7 @@ func (overlay Overlay) Create() error { if util.IsDir(overlay.Path()) { return fmt.Errorf("overlay already exists: %s", overlay) } - return os.MkdirAll(overlay.Rootfs(), 0755) + return os.MkdirAll(overlay.Rootfs(), 0o755) } // Creates a site overlay from an existing distribution overlay. @@ -69,7 +69,7 @@ func (overlay Overlay) CloneSiteOverlay() (siteOverlay Overlay, err error) { return siteOverlay, fmt.Errorf("site overlay already exists: %s", siteOverlay.Name()) } if !util.IsDir(filepath.Dir(overlay.Path())) { - if err := os.MkdirAll(filepath.Dir(overlay.Path()), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(overlay.Path()), 0o755); err != nil { return siteOverlay, err } } diff --git a/internal/pkg/overlay/overlay.go b/internal/pkg/overlay/overlay.go index 90dac978..6d45cd19 100644 --- a/internal/pkg/overlay/overlay.go +++ b/internal/pkg/overlay/overlay.go @@ -22,9 +22,7 @@ import ( "github.com/warewulf/warewulf/internal/pkg/wwlog" ) -var ( - ErrDoesNotExist = fmt.Errorf("overlay does not exist") -) +var ErrDoesNotExist = fmt.Errorf("overlay does not exist") // Overlay represents an overlay directory path. type Overlay string @@ -102,6 +100,69 @@ func (overlay Overlay) IsDistributionOverlay() bool { return path.Dir(overlay.Path()) == config.Get().Paths.DistributionOverlaydir() } +// DeleteFile deletes a file or the entire overlay directory. +// If the file belongs to a distribution overlay, it will be cloned to a site overlay +// before deletion. +func (overlay Overlay) DeleteFile(filePath string, force, cleanup bool) error { + wwlog.Info("Deleting file %s from overlay %s, force: %v, cleanup: %v", filePath, overlay.Name(), force, cleanup) + if filePath == "" { + if force { + err := os.RemoveAll(overlay.Path()) + if err != nil { + return fmt.Errorf("failed to delete overlay forcely: %w", err) + } + } else { + err := os.Remove(overlay.Path()) + if err != nil { + return fmt.Errorf("failed to delete overlay: %w", err) + } + } + } else { + if overlay.IsDistributionOverlay() { + siteOverlay, err := overlay.CloneSiteOverlay() + if err != nil { + return fmt.Errorf("failed to clone distribution overlay '%s' to site overlay: %w", overlay.Name(), err) + } + // replace the overlay with newly creatd siteOverlay + overlay = siteOverlay + } + + fullPath := overlay.File(filePath) + if !util.IsFile(fullPath) { + return fmt.Errorf("file %s does not exist in overlay %s", filePath, overlay.Name()) + } + + if force { + if err := os.RemoveAll(fullPath); err != nil { + return fmt.Errorf("failed to delete file %s from overlay %s: %w", filePath, overlay.Name(), err) + } + } else { + if err := os.Remove(fullPath); err != nil { + return fmt.Errorf("failed to delete file %s from overlay %s: %w", filePath, overlay.Name(), err) + } + } + + if cleanup { + // cleanup the empty parents + i := path.Dir(fullPath) + for i != overlay.Rootfs() { + wwlog.Debug("Evaluating directory to remove: %s", i) + err := os.Remove(i) + if err != nil { + // if the directory is not empty, we stop here + if !os.IsNotExist(err) { + wwlog.Debug("Could not remove directory %s: %v", i, err) + } + break + } + wwlog.Debug("Removed empty directory: %s", i) + i = path.Dir(i) + } + } + } + return nil +} + func BuildAllOverlays(nodes []node.Node, allNodes []node.Node, workerCount int) error { nodeChan := make(chan node.Node, len(nodes)) errChan := make(chan error, len(nodes)*2) @@ -196,7 +257,7 @@ func BuildHostOverlay() error { if err != nil { return fmt.Errorf("could not build host overlay: %w ", err) } - if !(stats.Mode() == os.FileMode(0750|os.ModeDir) || stats.Mode() == os.FileMode(0700|os.ModeDir)) { + if !(stats.Mode() == os.FileMode(0o750|os.ModeDir) || stats.Mode() == os.FileMode(0o700|os.ModeDir)) { wwlog.SecWarn("Permissions of host overlay dir %s are %s (750 is considered as secure)", hostdir, stats.Mode()) } registry, err := node.New() @@ -257,7 +318,7 @@ func BuildOverlay(nodeConf node.Node, allNodes []node.Node, context string, over overlayImage := OverlayImage(nodeConf.Id(), context, overlayNames) overlayImageDir := path.Dir(overlayImage) - err := os.MkdirAll(overlayImageDir, 0750) + err := os.MkdirAll(overlayImageDir, 0o750) if err != nil { return fmt.Errorf("failed to create directory for %s: %s: %w", name, overlayImageDir, err) } @@ -292,8 +353,10 @@ func BuildOverlay(nodeConf node.Node, allNodes []node.Node, context string, over return err } -var regFile *regexp.Regexp -var regLink *regexp.Regexp +var ( + regFile *regexp.Regexp + regLink *regexp.Regexp +) func init() { regFile = regexp.MustCompile(`.*{{\s*/\*\s*file\s*["'](.*)["']\s*\*/\s*}}.*`) @@ -450,7 +513,6 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames [] return nil }) - if err != nil { return fmt.Errorf("failed to build overlay image directory: %w", err) } @@ -471,7 +533,6 @@ func CarefulWriteBuffer(destFile string, buffer bytes.Buffer, backupFile bool, p return fmt.Errorf("failed to create backup: %s -> %s.wwbackup %w", destFile, destFile, err) } } - } w, err := os.OpenFile(destFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) if err != nil { @@ -491,7 +552,8 @@ func RenderTemplateFile(fileName string, data TemplateStruct) ( buffer bytes.Buffer, backupFile bool, writeFile bool, - err error) { + err error, +) { backupFile = true writeFile = true // Build our FuncMap diff --git a/internal/pkg/warewulfd/api/api.go b/internal/pkg/warewulfd/api/api.go index d1f6e2dd..d2a941ae 100644 --- a/internal/pkg/warewulfd/api/api.go +++ b/internal/pkg/warewulfd/api/api.go @@ -72,6 +72,7 @@ func Handler(auth *config.Authentication, allowedNets []net.IPNet) *web.Service r.Method(http.MethodGet, "/{name}/file", nethttp.NewHandler(getOverlayFile())) r.Method(http.MethodPut, "/{name}", nethttp.NewHandler(createOverlay())) r.Method(http.MethodDelete, "/{name}", nethttp.NewHandler(deleteOverlay())) + r.Method(http.MethodDelete, "/{name}/file", nethttp.NewHandler(deleteOverlayFile())) }) }) diff --git a/internal/pkg/warewulfd/api/overlay.go b/internal/pkg/warewulfd/api/overlay.go index d3c0b1d0..11b3fe12 100644 --- a/internal/pkg/warewulfd/api/overlay.go +++ b/internal/pkg/warewulfd/api/overlay.go @@ -256,3 +256,35 @@ func deleteOverlay() usecase.Interactor { u.SetTags("Overlay") return u } + +func deleteOverlayFile() usecase.Interactor { + type deleteOverlayFileInput struct { + Name string `path:"name" required:"true" description:"Name of overlay to get a file from"` + Path string `query:"path" required:"true" description:"Path to file to get from an overlay"` + Force bool `query:"force" default:"false" description:"Whether to forcely delete a overlay, default:'false'"` + Cleanup bool `query:"cleanup" default:"false" description:"Whether to cleanup empty parent directories, default:'false'"` + } + + u := usecase.NewInteractor(func(ctx context.Context, input deleteOverlayFileInput, output *OverlayResponse) error { + wwlog.Debug("api.deleteOverlayFile(Name:%v, Path:%v)", input.Name, input.Path) + if input.Path == "" { + return status.Wrap(fmt.Errorf("must specify a path"), status.InvalidArgument) + } + + if relPath, err := url.QueryUnescape(input.Path); err != nil { + return fmt.Errorf("failed to decode path: %v: %w", input.Path, err) + } else { + overlay_ := overlay.GetOverlay(input.Name) + err := overlay_.DeleteFile(relPath, input.Force, input.Cleanup) + if err != nil { + return fmt.Errorf("unable to delete overlay file %v: %v: %w", input.Name, relPath, err) + } + } + *output = *NewOverlayResponse(input.Name) + return nil + }) + u.SetTitle("Delete a file from an overlay") + u.SetDescription("Delete a file from an overlay from the overlay name and file path") + u.SetTags("Overlay") + return u +} diff --git a/internal/pkg/warewulfd/api/overlay_test.go b/internal/pkg/warewulfd/api/overlay_test.go index c9ab26ee..08439209 100644 --- a/internal/pkg/warewulfd/api/overlay_test.go +++ b/internal/pkg/warewulfd/api/overlay_test.go @@ -117,6 +117,20 @@ func TestOverlayAPI(t *testing.T) { assert.JSONEq(t, `{"test":{"files":null, "site":true},"testoverlay":{"files":["/email.ww"], "site":false}}`, string(body)) }) + t.Run("test delete overlay file", func(t *testing.T) { + req, err := http.NewRequest(http.MethodDelete, srv.URL+"/api/overlays/testoverlay/file?path=email.ww&force=true", nil) + assert.NoError(t, err) + + resp, err := http.DefaultTransport.RoundTrip(req) + assert.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.NoError(t, resp.Body.Close()) + + assert.JSONEq(t, `{"files":null, "site":true}`, string(body)) + }) + t.Run("test delete overlays", func(t *testing.T) { req, err := http.NewRequest(http.MethodDelete, srv.URL+"/api/overlays/test?force=true", nil) assert.NoError(t, err)