diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad616c5..89bd7f69 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 `PUT|DELETE /api/overlays/{name}/file?path={path}` - Added `DELETE /api/overlays/{name}/file?path={path}` ### Changed diff --git a/internal/pkg/overlay/overlay.go b/internal/pkg/overlay/overlay.go index e48427bd..ff418f5e 100644 --- a/internal/pkg/overlay/overlay.go +++ b/internal/pkg/overlay/overlay.go @@ -114,6 +114,25 @@ func (overlay Overlay) IsDistributionOverlay() bool { return path.Dir(overlay.Path()) == config.Get().Paths.DistributionOverlaydir() } +func (overlay Overlay) CreateOverlayFile(filePath string, content []byte, force bool) error { + wwlog.Info("Creating file %s in overlay %s, force: %v", filePath, overlay.Name(), force) + fullPath := overlay.File(filePath) + // create necessary parent directories + if err := os.MkdirAll(path.Dir(fullPath), 0o755); err != nil { + return fmt.Errorf("failed to create parent directories for %s: %w", fullPath, err) + } + + // if the file already exists and force is false, return an error + if util.IsFile(fullPath) { + if force { + return os.WriteFile(fullPath, content, 0o644) + } + return fmt.Errorf("file %s already exists in overlay %s", filePath, overlay.Name()) + } + + return os.WriteFile(fullPath, content, 0o644) +} + // 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. diff --git a/internal/pkg/overlay/overlay_test.go b/internal/pkg/overlay/overlay_test.go index 6f8d44d8..ae7c8af0 100644 --- a/internal/pkg/overlay/overlay_test.go +++ b/internal/pkg/overlay/overlay_test.go @@ -20,7 +20,7 @@ import ( ) func Test_FindOverlays(t *testing.T) { - var tests = map[string]struct { + tests := map[string]struct { distOverlays []string siteOverlays []string overlayList []string @@ -75,7 +75,7 @@ func Test_OverlayMethods(t *testing.T) { env.WriteFile(path.Join(sitedir, "both/rootfs/testfile"), "the site version") env.WriteFile(path.Join(distdir, "both/rootfs/testfile"), "the distribution version") - var tests = map[string]struct { + tests := map[string]struct { name string path string rootfs string @@ -157,7 +157,7 @@ func Test_OverlayMethods(t *testing.T) { } func Test_BuildOverlayIndir(t *testing.T) { - var tests = map[string]struct { + tests := map[string]struct { node node.Node overlays []string overlayFiles map[string]string @@ -336,7 +336,7 @@ Tags:map[] } func Test_BuildOverlay(t *testing.T) { - var tests = []struct { + tests := []struct { description string nodeName string context string @@ -377,7 +377,7 @@ func Test_BuildOverlay(t *testing.T) { overlays: []string{"o1"}, image: "o1.img", contents: []string{"o1.txt"}, - perms: []int{0644}, + perms: []int{0o644}, }, { description: "if multiple overlays are specified without a node, then the combined overlay is built directly in the overlay directory", @@ -386,7 +386,7 @@ func Test_BuildOverlay(t *testing.T) { overlays: []string{"o1", "o2"}, image: "o1-o2.img", contents: []string{"o1.txt", "o2.txt"}, - perms: []int{0644, 0644}, + perms: []int{0o644, 0o644}, }, { description: "if a single node overlay is specified, then the overlay is built in a node overlay directory", @@ -395,7 +395,7 @@ func Test_BuildOverlay(t *testing.T) { overlays: []string{"o1"}, image: "node1/o1.img", contents: []string{"o1.txt"}, - perms: []int{0644}, + perms: []int{0o644}, }, { description: "if multiple node overlays are specified, then the combined overlay is built in a node overlay directory", @@ -404,7 +404,7 @@ func Test_BuildOverlay(t *testing.T) { overlays: []string{"o1", "o2"}, image: "node1/o1-o2.img", contents: []string{"o1.txt", "o2.txt"}, - perms: []int{0644, 0644}, + perms: []int{0o644, 0o644}, }, { description: "if no node system overlays are specified, then context pointed overlay is generated", @@ -429,7 +429,7 @@ func Test_BuildOverlay(t *testing.T) { overlays: []string{"o1"}, image: "node1/__SYSTEM__.img", contents: []string{"o1.txt"}, - perms: []int{0644}, + perms: []int{0o644}, }, { description: "if a single node runtime overlay is specified, then a runtime overlay image is generated in a node overlay directory", @@ -438,7 +438,7 @@ func Test_BuildOverlay(t *testing.T) { overlays: []string{"o1"}, image: "node1/__RUNTIME__.img", contents: []string{"o1.txt"}, - perms: []int{0644}, + perms: []int{0o644}, }, { description: "if multiple node system overlays are specified, then a system overlay image is generated with the contents of both overlays", @@ -447,7 +447,7 @@ func Test_BuildOverlay(t *testing.T) { overlays: []string{"o1", "o2"}, image: "node1/__SYSTEM__.img", contents: []string{"o1.txt", "o2.txt"}, - perms: []int{0644, 0644}, + perms: []int{0o644, 0o644}, }, { description: "if multiple node runtime overlays are specified, then a runtime overlay image is generated with the contents of both overlays", @@ -456,7 +456,7 @@ func Test_BuildOverlay(t *testing.T) { overlays: []string{"o1", "o2"}, image: "node1/__RUNTIME__.img", contents: []string{"o1.txt", "o2.txt"}, - perms: []int{0644, 0644}, + perms: []int{0o644, 0o644}, }, { description: "validating altered permissions are retained", @@ -465,7 +465,7 @@ func Test_BuildOverlay(t *testing.T) { overlays: []string{"o3"}, image: "node1/__RUNTIME__.img", contents: []string{"subdir", "subdir/o3.txt"}, - perms: []int{0700, 0600}, + perms: []int{0o700, 0o600}, }, } @@ -473,12 +473,12 @@ func Test_BuildOverlay(t *testing.T) { defer env.RemoveAll() env.CreateFile("var/lib/warewulf/overlays/o1/rootfs/o1.txt") - env.Chmod("var/lib/warewulf/overlays/o1/rootfs/o1.txt", 0644) + env.Chmod("var/lib/warewulf/overlays/o1/rootfs/o1.txt", 0o644) env.CreateFile("var/lib/warewulf/overlays/o2/rootfs/o2.txt") - env.Chmod("var/lib/warewulf/overlays/o2/rootfs/o2.txt", 0644) + env.Chmod("var/lib/warewulf/overlays/o2/rootfs/o2.txt", 0o644) env.CreateFile("var/lib/warewulf/overlays/o3/rootfs/subdir/o3.txt.ww") - env.Chmod("var/lib/warewulf/overlays/o3/rootfs/subdir", 0700) - env.Chmod("var/lib/warewulf/overlays/o3/rootfs/subdir/o3.txt.ww", 0600) + env.Chmod("var/lib/warewulf/overlays/o3/rootfs/subdir", 0o700) + env.Chmod("var/lib/warewulf/overlays/o3/rootfs/subdir/o3.txt.ww", 0o600) for _, tt := range tests { nodeInfo := node.NewNode(tt.nodeName) @@ -508,7 +508,7 @@ func Test_BuildOverlay(t *testing.T) { } func Test_BuildAllOverlays(t *testing.T) { - var tests = []struct { + tests := []struct { description string nodes []string systemOverlays [][]string @@ -585,8 +585,8 @@ func Test_BuildAllOverlays(t *testing.T) { assert.NoError(t, overlayDirErr) defer os.RemoveAll(overlayDir) conf.Paths.WWOverlaydir = overlayDir - assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o1"), 0700)) - assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o2"), 0700)) + assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o1"), 0o700)) + assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o2"), 0o700)) for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { @@ -620,7 +620,7 @@ func Test_BuildAllOverlays(t *testing.T) { } func Test_BuildSpecificOverlays(t *testing.T) { - var tests = []struct { + tests := []struct { description string nodes []string overlays []string @@ -683,8 +683,8 @@ func Test_BuildSpecificOverlays(t *testing.T) { assert.NoError(t, overlayDirErr) defer os.RemoveAll(overlayDir) conf.Paths.WWOverlaydir = overlayDir - assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o1"), 0700)) - assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o2"), 0700)) + assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o1"), 0o700)) + assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o2"), 0o700)) for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { @@ -711,6 +711,39 @@ func Test_BuildSpecificOverlays(t *testing.T) { } } +func Test_CreateOverlayFile(t *testing.T) { + tests := []struct { + name string + overlayName string + filePath string + content []byte + force bool + }{ + {"create file", "test", "newfile.ww", []byte("new file"), false}, + {"overwrite existing file", "test", "existingfile.ww", []byte("overwrite file"), true}, + } + + conf := warewulfconf.Get() + overlayDir, overlayDirErr := os.MkdirTemp(os.TempDir(), "ww-test-overlay-*") + assert.NoError(t, overlayDirErr) + defer os.RemoveAll(overlayDir) + conf.Paths.WWOverlaydir = overlayDir + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newOverlay := GetSiteOverlay(tt.overlayName) + err := newOverlay.CreateOverlayFile(tt.filePath, tt.content, tt.force) + assert.NoError(t, err) + + newFile := newOverlay.File(tt.filePath) + assert.FileExists(t, newFile) + readContent, err := os.ReadFile(newFile) + assert.NoError(t, err) + assert.Equal(t, tt.content, readContent) + }) + } +} + func dirIsEmpty(t *testing.T, name string) bool { f, err := os.Open(name) if err != nil { diff --git a/internal/pkg/util/util.go b/internal/pkg/util/util.go index e2091a64..ca6615f5 100644 --- a/internal/pkg/util/util.go +++ b/internal/pkg/util/util.go @@ -36,7 +36,6 @@ func FirstError(errs ...error) (err error) { } func DirModTime(path string) (time.Time, error) { - var lastTime time.Time err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -186,7 +185,8 @@ func FindFilterFiles( path string, includePattern []string, ignorePattern []string, - ignore_xdev bool) (ofiles []string, err error) { + ignore_xdev bool, +) (ofiles []string, err error) { wwlog.Debug("Finding files: %s include: %s ignore: %s", path, includePattern, ignorePattern) // Preprocess patterns to remove leading (and trailing) /, as we are handling relative paths @@ -347,7 +347,7 @@ Appending the lines to the given file */ func AppendLines(fileName string, lines []string) error { wwlog.Verbose("appending %v lines to %s", len(lines), fileName) - file, err := os.OpenFile(fileName, os.O_APPEND|os.O_WRONLY, 0644) + file, err := os.OpenFile(fileName, os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return fmt.Errorf("can't open file: %s: %w", fileName, err) } @@ -362,6 +362,21 @@ func AppendLines(fileName string, lines []string) error { return nil } +func OverwriteFile(fileName string, content []byte) error { + wwlog.Verbose("overwrite file %s", fileName) + file, err := os.OpenFile(fileName, os.O_RDWR|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("failed to open file: %s, err: %w", fileName, err) + } + defer file.Close() + + _, err = file.Write(content) + if err != nil { + return fmt.Errorf("while writing file: %s, err: %w", fileName, err) + } + return file.Sync() +} + /* ****************************************************************************** @@ -372,14 +387,15 @@ func CpioCreate( ifiles []string, ofile string, format string, - cpio_args ...string) (err error) { - + cpio_args ...string, +) (err error) { args := []string{ "--quiet", "--create", "--directory", rootdir, "--format", format, - "--file=" + ofile} + "--file=" + ofile, + } args = append(args, cpio_args...) @@ -413,13 +429,12 @@ func CpioCreate( Compress a file using gzip or pigz */ func FileGz( - file string) (err error) { - + file string, +) (err error) { file_gz := file + ".gz" if IsFile(file_gz) { err := os.Remove(file_gz) - if err != nil { return fmt.Errorf("could not remove existing file: %s: %w", file_gz, err) } @@ -509,9 +524,9 @@ func BuildFsImage( ignore []string, ignore_xdev bool, format string, - cpio_args ...string) (err error) { - - err = os.MkdirAll(path.Dir(imagePath), 0755) + cpio_args ...string, +) (err error) { + err = os.MkdirAll(path.Dir(imagePath), 0o755) if err != nil { return fmt.Errorf("failed to create image directory for %s: %s: %w", name, imagePath, err) } diff --git a/internal/pkg/util/util_test.go b/internal/pkg/util/util_test.go index d93f7660..0f64186d 100644 --- a/internal/pkg/util/util_test.go +++ b/internal/pkg/util/util_test.go @@ -9,7 +9,7 @@ import ( ) func Test_FindFiles(t *testing.T) { - var tests = map[string]struct { + tests := map[string]struct { createFiles []string findFiles []string }{ @@ -43,7 +43,7 @@ func Test_FindFiles(t *testing.T) { } func Test_FindFilterFiles(t *testing.T) { - var tests = map[string]struct { + tests := map[string]struct { createFiles []string include []string exclude []string @@ -92,3 +92,19 @@ func Test_FindFilterFiles(t *testing.T) { }) } } + +func Test_Overwrite(t *testing.T) { + t.Run("overwrite a file", func(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + env.CreateFile("file") + env.WriteFile("file", "hello world") + + assert.Equal(t, "hello world", env.ReadFile("file")) + + err := OverwriteFile(env.GetPath("file"), []byte("hello warewulf")) + assert.NoError(t, err) + + assert.Equal(t, "hello warewulf", env.ReadFile("file")) + }) +} diff --git a/internal/pkg/warewulfd/api/api.go b/internal/pkg/warewulfd/api/api.go index d2a941ae..de515235 100644 --- a/internal/pkg/warewulfd/api/api.go +++ b/internal/pkg/warewulfd/api/api.go @@ -71,8 +71,10 @@ func Handler(auth *config.Authentication, allowedNets []net.IPNet) *web.Service r.Method(http.MethodGet, "/{name}", nethttp.NewHandler(getOverlayByName())) r.Method(http.MethodGet, "/{name}/file", nethttp.NewHandler(getOverlayFile())) r.Method(http.MethodPut, "/{name}", nethttp.NewHandler(createOverlay())) + r.Method(http.MethodPut, "/{name}/file", nethttp.NewHandler(createOverlayFile())) r.Method(http.MethodDelete, "/{name}", nethttp.NewHandler(deleteOverlay())) r.Method(http.MethodDelete, "/{name}/file", nethttp.NewHandler(deleteOverlayFile())) + r.Method(http.MethodPut, "/{name}/file", nethttp.NewHandler(updateOverlayFile())) }) }) diff --git a/internal/pkg/warewulfd/api/overlay.go b/internal/pkg/warewulfd/api/overlay.go index 870d828b..a9fa497b 100644 --- a/internal/pkg/warewulfd/api/overlay.go +++ b/internal/pkg/warewulfd/api/overlay.go @@ -215,6 +215,39 @@ func createOverlay() usecase.Interactor { return u } +func createOverlayFile() usecase.Interactor { + type createOverlayFileInput struct { + Name string `path:"name" required:"true" description:"Name of overlay to create a file in"` + Path string `query:"path" required:"true" description:"Path to file to create in an overlay"` + Force bool `query:"force" default:"false" description:"Whether to forcefully create an overlay file, default:'false'"` + Content string `json:"content" required:"true" description:"Content of the file to create"` + } + + u := usecase.NewInteractor(func(ctx context.Context, input createOverlayFileInput, output *OverlayResponse) error { + wwlog.Debug("api.createOverlayFile(Name:%v, Path:%v, Force: %v)", input.Name, input.Path, input.Force) + if input.Path == "" { + return status.Wrap(fmt.Errorf("must specify a path"), status.InvalidArgument) + } + if input.Content == "" { + return status.Wrap(fmt.Errorf("content should not be empty"), status.InvalidArgument) + } + if relPath, err := url.QueryUnescape(input.Path); err != nil { + return fmt.Errorf("failed to decode path: %v: %w", input.Path, err) + } else { + newOverlay := overlay.GetOverlay(input.Name) + if err := newOverlay.CreateOverlayFile(relPath, []byte(input.Content), input.Force); err != nil { + return fmt.Errorf("unable to create overlay file %v: %v: %w", input.Name, relPath, err) + } + *output = *NewOverlayResponse(input.Name) + return nil + } + }) + u.SetTitle("Create a file in an overlay") + u.SetDescription("Create a file in an overlay from the overlay name and file path.") + u.SetTags("Overlay") + return u +} + func deleteOverlay() usecase.Interactor { type deleteOverlayInput struct { Name string `path:"name" required:"true" description:"Name of overlay to delete"` @@ -288,3 +321,37 @@ func deleteOverlayFile() usecase.Interactor { u.SetTags("Overlay") return u } + +func updateOverlayFile() usecase.Interactor { + type updateOverlayInput struct { + Name string `path:"name" required:"true" description:"Name of overlay to update"` + Path string `query:"path" required:"true" description:"Path to file to get from an overlay"` + Content string `json:"content" required:"true" description:"New content"` + } + + u := usecase.NewInteractor(func(ctx context.Context, input updateOverlayInput, output *OverlayResponse) error { + wwlog.Debug("api.updateOverlay(Name: %s)", input.Name) + if input.Path == "" { + return status.Wrap(fmt.Errorf("must specify a path"), status.InvalidArgument) + } + if input.Content == "" { + return status.Wrap(fmt.Errorf("new content should not be empty"), status.InvalidArgument) + } + *output = *NewOverlayResponse(input.Name) + + if relPath, err := url.QueryUnescape(input.Path); err != nil { + return status.Wrap(fmt.Errorf("failed to decode path"), status.InvalidArgument) + } else { + overlay_ := overlay.GetOverlay(input.Name) + file := overlay_.File(relPath) + if err := util.OverwriteFile(file, []byte(input.Content)); err != nil { + return fmt.Errorf("failed to overwrite file: %s, err: %w", file, err) + } + } + return nil + }) + u.SetTitle("Update an overlay") + u.SetDescription("Update an overlay") + u.SetTags("Overlay") + return u +} diff --git a/internal/pkg/warewulfd/api/overlay_test.go b/internal/pkg/warewulfd/api/overlay_test.go index 08439209..62cb15c8 100644 --- a/internal/pkg/warewulfd/api/overlay_test.go +++ b/internal/pkg/warewulfd/api/overlay_test.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "io" "net" "net/http" @@ -89,6 +90,46 @@ func TestOverlayAPI(t *testing.T) { }`) }) + t.Run("update overlay file", func(t *testing.T) { + req, err := http.NewRequest(http.MethodPut, srv.URL+"/api/overlays/testoverlay/file?path=email.ww", bytes.NewReader([]byte("{\"content\":\"hello world\"}"))) + assert.NoError(t, err) + + // set request + resp, err := http.DefaultTransport.RoundTrip(req) + assert.NoError(t, err) + + // validate the resp + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.NoError(t, resp.Body.Close()) + + assert.JSONEq(t, `{"files":["/email.ww"], "site":false}`, string(body)) + + // get again + req, err = http.NewRequest(http.MethodGet, srv.URL+"/api/overlays/testoverlay/file?path=email.ww", nil) + assert.NoError(t, err) + + // send request + resp, err = http.DefaultTransport.RoundTrip(req) + assert.NoError(t, err) + + // validate the resp + body, err = io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.NoError(t, resp.Body.Close()) + + ja := jsonassert.New(t) + ja.Assert(string(body), ` + { + "overlay": "testoverlay", + "path": "email.ww", + "contents": "hello world", + "perms": "<>", + "uid": "<>", + "gid": "<>" + }`) + }) + t.Run("create an overlay", func(t *testing.T) { req, err := http.NewRequest(http.MethodPut, srv.URL+"/api/overlays/test", nil) assert.NoError(t, err)