Use state-based routing instead of sentinel strings, so whitespace-trimming
syntax (e.g. `{{- file "name" -}}`) correctly creates all named files and
symlinks.
Fixes: #2118
Signed-off-by: Jonathon Anderson <janderson@ciq.com>
Co-authored-by: Christian Goll <cgoll@suse.com>
Signed-off-by: Jonathon Anderson <janderson@ciq.com>
379 lines
12 KiB
Go
379 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/swaggest/usecase"
|
|
"github.com/swaggest/usecase/status"
|
|
"github.com/warewulf/warewulf/internal/pkg/node"
|
|
"github.com/warewulf/warewulf/internal/pkg/overlay"
|
|
"github.com/warewulf/warewulf/internal/pkg/util"
|
|
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
|
)
|
|
|
|
type OverlayResponse struct {
|
|
Files []string `json:"files"`
|
|
Site bool `json:"site"`
|
|
}
|
|
|
|
func NewOverlayResponse(name string) *OverlayResponse {
|
|
o := new(OverlayResponse)
|
|
o.Files = []string{}
|
|
myOverlay, err := overlay.Get(name)
|
|
if err == nil {
|
|
if files, err := myOverlay.GetFiles(); err == nil {
|
|
o.Files = files
|
|
}
|
|
o.Site = myOverlay.IsSiteOverlay()
|
|
}
|
|
return o
|
|
}
|
|
|
|
func getOverlays() usecase.Interactor {
|
|
u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, output *map[string]*OverlayResponse) error {
|
|
wwlog.Debug("api.getOverlays()")
|
|
m := make(map[string]*OverlayResponse)
|
|
names := overlay.FindOverlays()
|
|
for _, name := range names {
|
|
m[name] = NewOverlayResponse(name)
|
|
}
|
|
*output = m
|
|
return nil
|
|
})
|
|
u.SetTitle("Get overlays")
|
|
u.SetDescription("Get all overlays.")
|
|
u.SetTags("Overlay")
|
|
return u
|
|
}
|
|
|
|
func getOverlayByName() usecase.Interactor {
|
|
type getOverlayByNameInput struct {
|
|
Name string `path:"name" required:"true" description:"Name of overlay to get"`
|
|
}
|
|
|
|
u := usecase.NewInteractor(func(ctx context.Context, input getOverlayByNameInput, output *OverlayResponse) error {
|
|
wwlog.Debug("api.getOverlayByName(Name:%v)", input.Name)
|
|
if _, err := overlay.Get(input.Name); err != nil {
|
|
return status.Wrap(fmt.Errorf("overlay not found: %v", input.Name), status.NotFound)
|
|
} else {
|
|
*output = *NewOverlayResponse(input.Name)
|
|
return nil
|
|
}
|
|
})
|
|
u.SetTitle("Get an overlay")
|
|
u.SetDescription("Get an overlay by its name.")
|
|
u.SetTags("Overlay")
|
|
return u
|
|
}
|
|
|
|
type OverlayFile struct {
|
|
Overlay string `json:"overlay"`
|
|
Path string `json:"path"`
|
|
Contents string `json:"contents"`
|
|
Perms os.FileMode `json:"perms"`
|
|
Uid uint32 `json:"uid"`
|
|
Gid uint32 `json:"gid"`
|
|
rendered bool
|
|
}
|
|
|
|
func (of *OverlayFile) FullPath() string {
|
|
if myOverlay, err := overlay.Get(of.Overlay); err != nil {
|
|
return ""
|
|
} else {
|
|
return myOverlay.File(of.Path)
|
|
}
|
|
}
|
|
|
|
func (of *OverlayFile) Exists() bool {
|
|
if myOverlay, err := overlay.Get(of.Overlay); err != nil {
|
|
return false
|
|
} else {
|
|
return myOverlay.Exists() && util.IsFile(of.FullPath())
|
|
}
|
|
}
|
|
|
|
func (of *OverlayFile) readContents() (string, error) {
|
|
fullPath := of.FullPath()
|
|
f, err := os.ReadFile(fullPath)
|
|
if err != nil {
|
|
wwlog.Warn("os.ReadFile err %v", err)
|
|
return "", err
|
|
}
|
|
// Populate the permissions, uid, and gid.
|
|
s, err := os.Stat(fullPath)
|
|
if err != nil {
|
|
wwlog.Warn("os.Stat err %v", err)
|
|
return "", err
|
|
}
|
|
fileMode := s.Mode()
|
|
of.Perms = fileMode & os.ModePerm
|
|
sys := s.Sys()
|
|
of.Uid = sys.(*syscall.Stat_t).Uid
|
|
of.Gid = sys.(*syscall.Stat_t).Gid
|
|
return string(f), err
|
|
}
|
|
|
|
func (of *OverlayFile) renderContents(nodeName string) (string, error) {
|
|
if path.Ext(of.Path) != ".ww" {
|
|
return "", fmt.Errorf("'%s' does not end with '.ww'", of.Path)
|
|
}
|
|
|
|
if of.rendered {
|
|
return "", fmt.Errorf("already rendered")
|
|
}
|
|
|
|
registry, regErr := node.New()
|
|
if regErr != nil {
|
|
return "", regErr
|
|
}
|
|
|
|
renderNode, nodeErr := registry.GetNode(nodeName)
|
|
if nodeErr != nil {
|
|
return "", nodeErr
|
|
}
|
|
|
|
allNodes, allNodesErr := registry.FindAllNodes()
|
|
if allNodesErr != nil {
|
|
return "", allNodesErr
|
|
}
|
|
|
|
tstruct, structErr := overlay.InitStruct(of.Overlay, renderNode, allNodes)
|
|
if structErr != nil {
|
|
return "", structErr
|
|
}
|
|
tstruct.BuildSource = of.Path
|
|
|
|
rendered, renderErr := overlay.RenderTemplate(of.FullPath(), tstruct)
|
|
if renderErr != nil {
|
|
return "", renderErr
|
|
}
|
|
|
|
if !rendered.WriteFile {
|
|
// abort() was called in the template: nothing would be written to disk.
|
|
// Include any content rendered before the abort() call for debugging.
|
|
return rendered.Files[0].Buffer.String() + "Aborted\n", nil
|
|
}
|
|
|
|
// Single output file with no file() calls: return content as-is for backward
|
|
// compatibility. The default slot has Name == ""; named entries always have a
|
|
// non-empty Name regardless of how many there are.
|
|
if len(rendered.Files) == 1 && rendered.Files[0].Name == "" {
|
|
if rendered.Files[0].IsSymlink {
|
|
return "Symlink: " + rendered.Files[0].Target + "\n", nil
|
|
}
|
|
return rendered.Files[0].Buffer.String(), nil
|
|
}
|
|
// One or more file() calls: emit Filename: / Symlink: headers for each named
|
|
// entry. Skip the default slot (Name == ""); pre-file() content in it is
|
|
// for RenderTemplateFile callers, not serialized here.
|
|
var combined strings.Builder
|
|
for _, f := range rendered.Files {
|
|
if f.Name == "" {
|
|
continue
|
|
}
|
|
combined.WriteString("Filename: ")
|
|
combined.WriteString(f.Name)
|
|
combined.WriteString("\n")
|
|
if f.IsSymlink {
|
|
combined.WriteString("Symlink: ")
|
|
combined.WriteString(f.Target)
|
|
combined.WriteString("\n")
|
|
} else {
|
|
combined.WriteString(f.Buffer.String())
|
|
}
|
|
}
|
|
return combined.String(), nil
|
|
}
|
|
|
|
func NewOverlayFile(name string, path string, renderNodeName string) (*OverlayFile, error) {
|
|
of := new(OverlayFile)
|
|
of.Overlay = name
|
|
of.Path = path
|
|
if renderNodeName == "" {
|
|
if contents, err := of.readContents(); err != nil {
|
|
return of, err
|
|
} else {
|
|
of.Contents = contents
|
|
}
|
|
} else {
|
|
if contents, err := of.renderContents(renderNodeName); err != nil {
|
|
return of, err
|
|
} else {
|
|
of.Contents = contents
|
|
}
|
|
}
|
|
return of, nil
|
|
}
|
|
|
|
func getOverlayFile() usecase.Interactor {
|
|
type getOverlayFileInput 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"`
|
|
Node string `query:"render" description:"ID of node to render a template for"`
|
|
}
|
|
|
|
u := usecase.NewInteractor(func(ctx context.Context, input getOverlayFileInput, output *OverlayFile) error {
|
|
wwlog.Debug("api.getOverlayFile(Name:%v, Path:%v, Node:%v)", input.Name, input.Path, input.Node)
|
|
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 {
|
|
if overlayFile, err := NewOverlayFile(input.Name, relPath, input.Node); err != nil {
|
|
return fmt.Errorf("unable to read overlay file %v: %v: %w", input.Name, relPath, err)
|
|
} else {
|
|
*output = *overlayFile
|
|
return nil
|
|
}
|
|
}
|
|
})
|
|
u.SetTitle("Get a file from an overlay")
|
|
u.SetDescription("Get a file from an overlay from the overlay name and file path, optionally rendered for a given node.")
|
|
u.SetTags("Overlay")
|
|
return u
|
|
}
|
|
|
|
func createOverlay() usecase.Interactor {
|
|
type createOverlayInput struct {
|
|
Name string `path:"name" required:"true" description:"Name of overlay to create"`
|
|
}
|
|
|
|
u := usecase.NewInteractor(func(ctx context.Context, input createOverlayInput, output *OverlayResponse) error {
|
|
wwlog.Debug("api.createOverlay(Name:%v)", input.Name)
|
|
if _, err := overlay.Get(input.Name); err == nil {
|
|
// existing overlay, return a conflict error
|
|
return status.Wrap(fmt.Errorf("%s overlay already exists", input.Name), status.AlreadyExists)
|
|
}
|
|
newOverlay, err := overlay.Create(input.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*output = *NewOverlayResponse(newOverlay.Name())
|
|
return nil
|
|
})
|
|
u.SetTitle("Create an overlay")
|
|
u.SetDescription("Create an overlay.")
|
|
u.SetTags("Overlay")
|
|
return u
|
|
}
|
|
|
|
func deleteOverlay() usecase.Interactor {
|
|
type deleteOverlayInput struct {
|
|
Name string `path:"name" required:"true" description:"Name of overlay to delete"`
|
|
Force bool `query:"force" default:"false" description:"Whether to delete a non-empty or in-use overlay, default:'false'"`
|
|
}
|
|
|
|
u := usecase.NewInteractor(func(ctx context.Context, input deleteOverlayInput, output *OverlayResponse) error {
|
|
wwlog.Debug("api.deleteOverlay(Name:%v, Force:%v)", input.Name, input.Force)
|
|
overlay_, err := overlay.Get(input.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if overlay_.IsDistributionOverlay() {
|
|
return status.Wrap(fmt.Errorf("distribution overlay: %s", overlay_.Name()), status.InvalidArgument)
|
|
}
|
|
|
|
if !input.Force {
|
|
if registry, err := node.New(); err != nil {
|
|
return err
|
|
} else {
|
|
nodesCount := len(registry.ListNodesUsingOverlay(input.Name))
|
|
profilesCount := len(registry.ListProfilesUsingOverlay(input.Name))
|
|
if nodesCount > 0 || profilesCount > 0 {
|
|
return status.Wrap(fmt.Errorf(
|
|
"overlay '%s' is in use by %v nodes and %v profiles", input.Name, nodesCount, profilesCount),
|
|
status.InvalidArgument)
|
|
}
|
|
}
|
|
}
|
|
if err := overlay_.Delete(input.Force); err != nil {
|
|
return status.Wrap(fmt.Errorf("failed to remove overlay: %v", err), status.Code(409))
|
|
}
|
|
*output = *NewOverlayResponse(input.Name)
|
|
output.Site = overlay_.IsSiteOverlay()
|
|
return nil
|
|
})
|
|
u.SetTitle("Delete an overlay")
|
|
u.SetDescription("Delete an overlay.")
|
|
u.SetTags("Overlay")
|
|
return u
|
|
}
|
|
|
|
func deleteOverlayFile() usecase.Interactor {
|
|
type deleteOverlayFileInput struct {
|
|
Name string `path:"name" required:"true" description:"Name of overlay to delete a file from"`
|
|
Path string `query:"path" required:"true" description:"Path to file to delete from an overlay"`
|
|
Force bool `query:"force" default:"false" description:"Whether to forcefully delete an overlay file, 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 {
|
|
if overlay_, err := overlay.Get(input.Name); err != nil {
|
|
return err
|
|
} else {
|
|
if err := overlay_.DeleteFile(relPath, input.Force, input.Cleanup); 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
|
|
}
|
|
|
|
func addOverlayFile() usecase.Interactor {
|
|
type createOverlayFileInput struct {
|
|
Name string `path:"name" required:"true" description:"Name of overlay to add a file to"`
|
|
Path string `query:"path" required:"true" description:"Path to file to create in the overlay"`
|
|
Content string `json:"content" required:"true" description:"Content of the file to create"`
|
|
IfNoneMatch string `header:"If-None-Match" description:"Set to '*' to indicate that the file should only be added if it does not already exist"`
|
|
}
|
|
|
|
u := usecase.NewInteractor(func(ctx context.Context, input createOverlayFileInput, output *OverlayResponse) error {
|
|
wwlog.Debug("api.addOverlayFile(Name:%v, Path:%v, IfNoneMatch: %v)", input.Name, input.Path, input.IfNoneMatch)
|
|
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_, err := overlay.Get(input.Name)
|
|
if err != nil {
|
|
if _, err := overlay.Create(input.Name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := overlay_.AddFile(relPath, []byte(input.Content), true, input.IfNoneMatch != "*"); err != nil {
|
|
return fmt.Errorf("unable to add overlay file %v: %v: %w", input.Name, relPath, err)
|
|
}
|
|
*output = *NewOverlayResponse(input.Name)
|
|
return nil
|
|
}
|
|
})
|
|
u.SetTitle("Add a file to an overlay")
|
|
u.SetDescription("Add a file to an overlay from the overlay name and file path.")
|
|
u.SetTags("Overlay")
|
|
return u
|
|
}
|