Add a new OpenAPI v3 REST API to warewulfd at /api

Co-authored-by: jason yang <jasonyangshadow@gmail.com>

Signed-off-by: Jonathon Anderson <janderson@ciq.com>
This commit is contained in:
Jonathon Anderson
2025-03-02 13:32:41 -07:00
parent bf0fc8bb27
commit 4005afac4c
42 changed files with 2730 additions and 345 deletions

View File

@@ -0,0 +1,80 @@
package api
import (
"net"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/swaggest/openapi-go/openapi3"
"github.com/swaggest/rest/nethttp"
"github.com/swaggest/rest/web"
swgui "github.com/swaggest/swgui/v5emb"
"github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/version"
)
func Handler(auth *config.Authentication, allowedNets []net.IPNet) *web.Service {
api := web.NewService(openapi3.NewReflector())
api.OpenAPISchema().SetTitle("Warewulf v4 API")
api.OpenAPISchema().SetDescription("This service provides an API to a Warewulf v4 server.")
api.OpenAPISchema().SetVersion(version.GetVersion())
api.Route("/api/nodes", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(auth, allowedNets))
r.Method(http.MethodGet, "/", nethttp.NewHandler(getNodes()))
r.Method(http.MethodGet, "/{id}", nethttp.NewHandler(getNodeByID()))
r.Method(http.MethodGet, "/{id}/raw", nethttp.NewHandler(getRawNodeByID()))
r.Method(http.MethodPut, "/{id}", nethttp.NewHandler(addNode()))
r.Method(http.MethodDelete, "/{id}", nethttp.NewHandler(deleteNode()))
r.Method(http.MethodPatch, "/{id}", nethttp.NewHandler(updateNode()))
r.Method(http.MethodGet, "/{id}/fields", nethttp.NewHandler(getNodeFields()))
r.Method(http.MethodPost, "/overlays/build", nethttp.NewHandler(buildAllOverlays()))
r.Method(http.MethodPost, "/{id}/overlays/build", nethttp.NewHandler(buildOverlays()))
})
})
api.Route("/api/profiles", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(auth, allowedNets))
r.Method(http.MethodGet, "/", nethttp.NewHandler(getProfiles()))
r.Method(http.MethodGet, "/{id}", nethttp.NewHandler(getProfileByID()))
r.Method(http.MethodPut, "/{id}", nethttp.NewHandler(addProfile()))
r.Method(http.MethodPatch, "/{id}", nethttp.NewHandler(updateProfile()))
r.Method(http.MethodDelete, "/{id}", nethttp.NewHandler(deleteProfile()))
})
})
api.Route("/api/images", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(auth, allowedNets))
r.Method(http.MethodGet, "/", nethttp.NewHandler(getImages()))
r.Method(http.MethodGet, "/{name}", nethttp.NewHandler(getImageByName()))
r.Method(http.MethodPost, "/{name}/import", nethttp.NewHandler(importImage()))
r.Method(http.MethodPatch, "/{name}", nethttp.NewHandler(updateImage()))
r.Method(http.MethodPost, "/{name}/build", nethttp.NewHandler(buildImage()))
r.Method(http.MethodDelete, "/{name}", nethttp.NewHandler(deleteImage()))
})
})
api.Route("/api/overlays", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(auth, allowedNets))
r.Method(http.MethodGet, "/", nethttp.NewHandler(getOverlays()))
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.MethodDelete, "/{name}", nethttp.NewHandler(deleteOverlay()))
})
})
api.Docs("/api/docs", swgui.New)
return api
}

View File

@@ -0,0 +1,56 @@
package api
import (
"fmt"
"net"
"net/http"
"github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
func AuthMiddleware(auth *config.Authentication, allowedNets []net.IPNet) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
wwlog.Debug("allowed subnets: %v", allowedNets)
wwlog.Debug("remote address: %v", r.RemoteAddr)
fromAllowedNet := false
if ipStr, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
ip := net.ParseIP(ipStr)
if ip == nil {
http.Error(w, fmt.Sprintf("Invalid remote address: %v", r.RemoteAddr), http.StatusForbidden)
}
for _, allowedNet := range allowedNets {
if allowedNet.Contains(ip) {
fromAllowedNet = true
break
}
}
if !fromAllowedNet {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
} else {
http.Error(w, fmt.Sprintf("Invalid remote address: %v", r.RemoteAddr), http.StatusForbidden)
return
}
if auth != nil {
username, password, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
_, err := auth.Authenticate(username, password)
if err != nil {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,213 @@
package api
import (
"context"
"fmt"
"strings"
"github.com/swaggest/usecase"
"github.com/swaggest/usecase/status"
image_api "github.com/warewulf/warewulf/internal/pkg/api/image"
"github.com/warewulf/warewulf/internal/pkg/api/routes/wwapiv1"
"github.com/warewulf/warewulf/internal/pkg/image"
"github.com/warewulf/warewulf/internal/pkg/kernel"
"github.com/warewulf/warewulf/internal/pkg/node"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
type Image struct {
Kernels []string `json:"kernels"`
Size int `json:"size"`
BuildTime int64 `json:"buildtime"`
Writable bool `json:"writable"`
}
func NewImage(name string) *Image {
c := new(Image)
c.Kernels = []string{}
for _, k := range kernel.FindKernels(name) {
c.Kernels = append(c.Kernels, k.Path)
}
c.Size = image.ImageSize(name)
modTime := image.ImageModTime(name)
if modTime.IsZero() {
c.BuildTime = 0
} else {
c.BuildTime = modTime.Unix()
}
c.Writable = image.IsWriteAble(name)
return c
}
func getImages() usecase.Interactor {
u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, output *map[string]*Image) error {
wwlog.Debug("api.getImages()")
m := make(map[string]*Image)
if names, err := image.ListSources(); err != nil {
return err
} else {
for _, name := range names {
m[name] = NewImage(name)
}
*output = m
return nil
}
})
u.SetTitle("Get images")
u.SetDescription("Get all node images")
u.SetTags("Image")
return u
}
func getImageByName() usecase.Interactor {
type getImageByNameInput struct {
Name string `path:"name" required:"true" description:"Name of image to add"`
}
u := usecase.NewInteractor(func(ctx context.Context, input getImageByNameInput, output *Image) error {
wwlog.Debug("api.getImageByName(Name:%v)", input.Name)
if !image.ValidSource(input.Name) {
return status.Wrap(fmt.Errorf("image not found: %v", input.Name), status.NotFound)
} else {
*output = *NewImage(input.Name)
return nil
}
})
u.SetTitle("Get an image")
u.SetDescription("Get a node image by its name")
u.SetTags("Image")
return u
}
func importImage() usecase.Interactor {
type importImageInput struct {
Name string `path:"name" required:"true" description:"Name of image to import"`
URI string `json:"uri" required:"true" description:"OCI registry URI to import image definition from"`
NoHttps bool `json:"nohttps" default:"false" description:"Use http, rather than https, to communicate with the registry, default:'false'"`
User string `json:"user" description:"Username for the registry, if needed"`
Password string `json:"password" description:"Password for the registry, if needed"`
}
u := usecase.NewInteractor(func(ctx context.Context, input importImageInput, output *Image) error {
wwlog.Debug("api.importImage(Name:%v, URI:%v, NoHttps:%v, User:%v, Password:[redacted])",
input.Name, input.URI, input.NoHttps, input.User)
if !strings.HasPrefix(input.URI, "docker://") {
return status.Wrap(fmt.Errorf("missing docker:// prefix: %s", input.URI), status.InvalidArgument)
}
if !image.ValidName(input.Name) {
return status.Wrap(fmt.Errorf("name contains illegal characters: %s", input.Name), status.InvalidArgument)
}
if sctx, err := image_api.GetSystemContext(input.NoHttps, input.User, input.Password, ""); err != nil {
return err
} else {
if err := image.ImportDocker(input.URI, input.Name, sctx); err != nil {
return err
}
*output = *NewImage(input.Name)
return nil
}
})
u.SetTitle("Import an image")
u.SetDescription("Import a node image from an OCI registry")
u.SetTags("Image")
return u
}
func deleteImage() usecase.Interactor {
type deleteImageInput struct {
Name string `path:"name" required:"true" description:"Name of image to delete"`
}
u := usecase.NewInteractor(func(ctx context.Context, input deleteImageInput, output *Image) error {
wwlog.Debug("api.deleteImage(Name:%v)", input.Name)
if image.ValidSource(input.Name) {
*output = *NewImage(input.Name)
}
if registry, err := node.New(); err != nil {
return err
} else {
nodesCount := len(registry.ListNodesUsingImage(input.Name))
profilesCount := len(registry.ListProfilesUsingImage(input.Name))
if nodesCount > 0 || profilesCount > 0 {
return status.Wrap(fmt.Errorf(
"image '%s' is in use by %v nodes and %v profiles", input.Name, nodesCount, profilesCount),
status.InvalidArgument)
}
}
cdp := &wwapiv1.ImageDeleteParameter{
ImageNames: []string{input.Name},
}
return image_api.ImageDelete(cdp)
})
u.SetTitle("Delete an image")
u.SetDescription("Delete an existing node image")
u.SetTags("Image")
return u
}
func updateImage() usecase.Interactor {
type renameImageInput struct {
Name string `path:"name" required:"true" description:"Name of image to update"`
NewName string `json:"name" description:"New name to rename the image to"`
Build bool `query:"build" default:"true" description:"Build the image image after renaming, default:'true'"`
}
u := usecase.NewInteractor(func(ctx context.Context, input renameImageInput, output *Image) error {
wwlog.Debug("api.updateImage(Name:%v, NewName:%v, Build:%v)", input.Name, input.NewName, input.Build)
name := input.Name
if input.NewName != "" {
crp := &wwapiv1.ImageRenameParameter{
ImageName: input.Name,
TargetName: input.NewName,
Build: input.Build,
}
if err := image_api.ImageRename(crp); err != nil {
return err
}
name = input.NewName
}
*output = *NewImage(name)
return nil
})
u.SetTitle("Update or rename an image")
u.SetDescription("Update or rename an existing node image")
u.SetTags("Image")
return u
}
func buildImage() usecase.Interactor {
type buildImageInput struct {
Name string `path:"name" required:"true" description:"Name of image to build"`
Force bool `query:"force" default:"false" description:"Build the image image even if it appears unnecessary, default:'false'"`
}
u := usecase.NewInteractor(func(ctx context.Context, input buildImageInput, output *Image) error {
wwlog.Debug("api.buildImage(Name:%v, Force:%v)", input.Name, input.Force)
cbp := &wwapiv1.ImageBuildParameter{
ImageNames: []string{input.Name},
Force: input.Force,
}
if err := image_api.ImageBuild(cbp); err != nil {
return err
}
*output = *NewImage(input.Name)
return nil
})
u.SetTitle("Build an image")
u.SetDescription("Build a node image")
u.SetTags("Image")
return u
}

View File

@@ -0,0 +1,144 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"path"
"testing"
"github.com/stretchr/testify/assert"
"github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/testenv"
)
func TestImageAPI(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
authData := `
users:
- name: admin
password hash: $2b$05$5QVWDpiWE7L4SDL9CYdi3O/l6HnbNOLoXgY2sa1bQQ7aSBKdSqvsC
`
auth := config.NewAuthentication()
err := auth.ParseFromRaw([]byte(authData))
assert.NoError(t, err)
allowedNets := []net.IPNet{
{
IP: net.IPv4(127, 0, 0, 0),
Mask: net.CIDRMask(8, 32),
},
}
srv := httptest.NewServer(Handler(auth, allowedNets))
defer srv.Close()
env.WriteFile(path.Join(testenv.WWChrootdir, "test-image/rootfs/file"), `test`)
t.Run("test no authentication", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/images", nil)
assert.NoError(t, err)
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.Equal(t, resp.StatusCode, http.StatusUnauthorized)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
assert.Equal(t, "Unauthorized\n", string(body))
})
t.Run("test get all images", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/images", nil)
assert.NoError(t, err)
req.SetBasicAuth("admin", "admin")
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
assert.JSONEq(t, `{"test-image": {"kernels":[], "size":0, "buildtime":0, "writable":true}}`, string(body))
})
t.Run("test get single image", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/images/test-image", nil)
assert.NoError(t, err)
req.SetBasicAuth("admin", "admin")
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
assert.JSONEq(t, `{"kernels":[] ,"size":0, "buildtime":0, "writable":true}`, string(body))
})
t.Run("test build image", func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/images/test-image/build?force=true&default=true", nil)
assert.NoError(t, err)
req.SetBasicAuth("admin", "admin")
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
var bodyData map[string]interface{}
assert.NoError(t, json.Unmarshal([]byte(body), &bodyData))
assert.True(t, bodyData["buildtime"].(float64) > 0.0)
bodyData["buildtime"] = 0.0
assert.Equal(t, map[string]interface{}{"kernels": []interface{}{}, "size": 512.0, "buildtime": 0.0, "writable": true}, bodyData)
})
t.Run("test rename image", func(t *testing.T) {
req, err := http.NewRequest(http.MethodPatch, srv.URL+"/api/images/test-image?build=true", bytes.NewBuffer([]byte(`{"name": "new-image"}`)))
assert.NoError(t, err)
req.SetBasicAuth("admin", "admin")
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
var bodyData map[string]interface{}
assert.NoError(t, json.Unmarshal([]byte(body), &bodyData))
assert.True(t, bodyData["buildtime"].(float64) > 0.0)
bodyData["buildtime"] = 0.0
assert.Equal(t, map[string]interface{}{"kernels": []interface{}{}, "size": 512.0, "buildtime": 0.0, "writable": true}, bodyData)
})
t.Run("test delete image", func(t *testing.T) {
req, err := http.NewRequest(http.MethodDelete, srv.URL+"/api/images/new-image", nil)
assert.NoError(t, err)
req.SetBasicAuth("admin", "admin")
// send request
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
// validate the resp
body, err := io.ReadAll(resp.Body)
assert.Equal(t, resp.StatusCode, http.StatusOK)
assert.NoError(t, err)
var bodyData map[string]interface{}
assert.NoError(t, json.Unmarshal([]byte(body), &bodyData))
assert.True(t, bodyData["buildtime"].(float64) > 0.0)
bodyData["buildtime"] = 0.0
assert.Equal(t, map[string]interface{}{"kernels": []interface{}{}, "size": 512.0, "buildtime": 0.0, "writable": true}, bodyData)
})
}

View File

@@ -0,0 +1,303 @@
package api
import (
"context"
"fmt"
"runtime"
"sort"
"dario.cat/mergo"
"github.com/swaggest/usecase"
"github.com/swaggest/usecase/status"
"github.com/warewulf/warewulf/internal/pkg/image"
"github.com/warewulf/warewulf/internal/pkg/node"
"github.com/warewulf/warewulf/internal/pkg/overlay"
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
func getNodes() usecase.Interactor {
u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, output *map[string]*node.Node) error {
wwlog.Debug("api.getNodes()")
if registry, err := node.New(); err != nil {
return err
} else {
nodeMap := make(map[string]*node.Node)
if nodeList, err := registry.FindAllNodes(); err != nil {
return err
} else {
for i := range nodeList {
nodeMap[nodeList[i].Id()] = &nodeList[i]
}
*output = nodeMap
return nil
}
}
})
u.SetTitle("Get nodes")
u.SetDescription("Get all nodes, including field values from associated profiles.")
u.SetTags("Node")
return u
}
func getNodeByID() usecase.Interactor {
type getNodeByIDInput struct {
ID string `path:"id" required:"true" description:"ID of node to get"`
}
u := usecase.NewInteractor(func(ctx context.Context, input getNodeByIDInput, output *node.Node) error {
wwlog.Debug("api.getNodeByID(ID:%v)", input.ID)
if registry, err := node.New(); err != nil {
return err
} else {
if node_, err := registry.GetNode(input.ID); err != nil {
return status.Wrap(fmt.Errorf("node not found: %v (%v)", input.ID, err), status.NotFound)
} else {
*output = node_
return nil
}
}
})
u.SetTitle("Get a node")
u.SetDescription("Get a node by its ID, including field values from associated profiles.")
u.SetTags("Node")
u.SetExpectedErrors(status.NotFound)
return u
}
func getRawNodeByID() usecase.Interactor {
type getNodeByIDInput struct {
ID string `path:"id" required:"true" description:"ID of node to get"`
}
u := usecase.NewInteractor(func(ctx context.Context, input getNodeByIDInput, output *node.Node) error {
wwlog.Debug("api.getRawNodeByID(ID:%v)", input.ID)
if registry, err := node.New(); err != nil {
return err
} else {
if node_, ok := registry.Nodes[input.ID]; !ok {
return status.Wrap(fmt.Errorf("node not found: %v", input.ID), status.NotFound)
} else {
*output = *node_
return nil
}
}
})
u.SetTitle("Get a raw node")
u.SetDescription("Get a node by its ID, without field values from associated profiles.")
u.SetTags("Node")
u.SetExpectedErrors(status.NotFound)
return u
}
func getNodeFields() usecase.Interactor {
type getNodeByIDInput struct {
ID string `path:"id" required:"true" description:"ID of node from which to retrieve fields"`
}
u := usecase.NewInteractor(func(ctx context.Context, input getNodeByIDInput, output *[]node.Field) error {
wwlog.Debug("api.getNodeFields(ID:%v)", input.ID)
if registry, err := node.New(); err != nil {
return err
} else {
if n, fields, err := registry.MergeNode(input.ID); err != nil {
return status.Wrap(fmt.Errorf("node not found: %v (%v)", input.ID, err), status.NotFound)
} else {
*output = fields.List(n)
return nil
}
}
})
u.SetTitle("Get node fields")
u.SetDescription("Get the fields and values of a node, indicating which profiles each field originates from.")
u.SetTags("Node")
u.SetExpectedErrors(status.NotFound)
return u
}
func addNode() usecase.Interactor {
type addNodeInput struct {
ID string `path:"id" required:"true" description:"ID of node to be added"`
Node node.Node `json:"node" required:"true" description:"Field values in JSON format for added node"`
}
u := usecase.NewInteractor(func(ctx context.Context, input addNodeInput, output *node.Node) error {
wwlog.Debug("api.addNode(ID:%v, Node:%+v)", input.ID, input.Node)
if registry, err := node.New(); err != nil {
return err
} else {
for _, profile := range input.Node.Profiles {
if _, ok := registry.NodeProfiles[profile]; !ok {
return status.Wrap(fmt.Errorf("profile '%s' does not exist", profile), status.InvalidArgument)
}
}
if input.Node.ImageName != "" && !image.ValidSource(input.Node.ImageName) {
return status.Wrap(fmt.Errorf("image '%s' does not exist", input.Node.ImageName), status.InvalidArgument)
}
for _, overlay_ := range input.Node.SystemOverlay {
if !overlay.GetOverlay(overlay_).Exists() {
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
}
}
for _, overlay_ := range input.Node.RuntimeOverlay {
if !overlay.GetOverlay(overlay_).Exists() {
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
}
}
registry.Nodes[input.ID] = &input.Node
if err := registry.Persist(); err != nil {
return err
}
warewulfd.Reload()
*output = *(registry.Nodes[input.ID])
return nil
}
})
u.SetTitle("Add a node")
u.SetDescription("Add a new node.")
u.SetTags("Node")
return u
}
func deleteNode() usecase.Interactor {
type deleteNodeInput struct {
ID string `path:"id" required:"true" description:"ID of node to delete"`
}
u := usecase.NewInteractor(func(ctx context.Context, input deleteNodeInput, output *node.Node) error {
wwlog.Debug("api.deleteNode(ID:%v)", input.ID)
if registry, err := node.New(); err != nil {
return err
} else {
if node, ok := registry.Nodes[input.ID]; ok {
*output = *node
}
if err := registry.DelNode(input.ID); err != nil {
return err
}
if err := registry.Persist(); err != nil {
return err
}
warewulfd.Reload()
return nil
}
})
u.SetTitle("Delete a node")
u.SetDescription("Delete an existing node.")
u.SetTags("Node")
return u
}
func updateNode() usecase.Interactor {
type updateNodeInput struct {
ID string `path:"id" description:"ID of node to update"`
Node node.Node `json:"node" required:"true" description:"Field values in JSON format to update on node"`
}
u := usecase.NewInteractor(func(ctx context.Context, input updateNodeInput, output *node.Node) error {
wwlog.Debug("api.updateNode(ID:%v, Node:%+v)", input.ID, input.Node)
if registry, err := node.New(); err != nil {
return err
} else {
for _, profile := range input.Node.Profiles {
if _, ok := registry.NodeProfiles[profile]; !ok {
return status.Wrap(fmt.Errorf("profile '%s' does not exist", profile), status.InvalidArgument)
}
}
if input.Node.ImageName != "" && !image.ValidSource(input.Node.ImageName) {
return status.Wrap(fmt.Errorf("image '%s' does not exist", input.Node.ImageName), status.InvalidArgument)
}
for _, overlay_ := range input.Node.SystemOverlay {
if !overlay.GetOverlay(overlay_).Exists() {
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
}
}
for _, overlay_ := range input.Node.RuntimeOverlay {
if !overlay.GetOverlay(overlay_).Exists() {
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
}
}
if nodePtr, err := registry.GetNodeOnlyPtr(input.ID); err != nil {
return status.Wrap(err, status.NotFound)
} else {
if err := mergo.MergeWithOverwrite(nodePtr, &input.Node); err != nil {
return err
}
if err := registry.Persist(); err != nil {
return err
}
warewulfd.Reload()
*output = *nodePtr
return nil
}
}
})
u.SetTitle("Update a node")
u.SetDescription("Update an existing node.")
u.SetTags("Node")
return u
}
func buildAllOverlays() usecase.Interactor {
u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, output *[]string) error {
wwlog.Debug("api.buildAllOverlays()")
if registry, err := node.New(); err != nil {
return err
} else {
if nodes, err := registry.FindAllNodes(); err != nil {
return fmt.Errorf("could not get node list: %w", err)
} else {
ret := make([]string, len(nodes))
for i := range nodes {
ret[i] = nodes[i].Id()
}
sort.Strings(ret)
if err := overlay.BuildAllOverlays(nodes, nodes, runtime.NumCPU()); err != nil {
return err
}
*output = ret
return nil
}
}
})
u.SetTitle("Build all overlay images")
u.SetDescription("Build system and runtime overlay images for all nodes.")
u.SetTags("Node")
return u
}
func buildOverlays() usecase.Interactor {
type buildOverlayInput struct {
ID string `path:"id" description:"ID of node to build overlay images for"`
}
u := usecase.NewInteractor(func(ctx context.Context, input *buildOverlayInput, output *string) error {
wwlog.Debug("api.buildOverlays()")
if registry, err := node.New(); err != nil {
return err
} else {
nodes, err := registry.FindAllNodes()
if err != nil {
return err
}
if node_, err := registry.GetNode(input.ID); err != nil {
return status.Wrap(err, status.NotFound)
} else {
if err := overlay.BuildAllOverlays([]node.Node{node_}, nodes, runtime.NumCPU()); err != nil {
return err
}
*output = input.ID
return nil
}
}
})
u.SetTitle("Build overlay images for a node")
u.SetDescription("Build system and runtime overlay images for a node.")
u.SetTags("Node")
return u
}

View File

@@ -0,0 +1,176 @@
package api
import (
"bytes"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/warewulf/warewulf/internal/pkg/testenv"
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
)
func TestNodeAPI(t *testing.T) {
warewulfd.SetNoDaemon()
env := testenv.New(t)
defer env.RemoveAll()
allowedNets := []net.IPNet{
{
IP: net.IPv4(127, 0, 0, 0),
Mask: net.CIDRMask(8, 32),
},
}
srv := httptest.NewServer(Handler(nil, allowedNets))
defer srv.Close()
t.Run("add a node", func(t *testing.T) {
// prepareration
testNode := `{
"node":{
"kernel": {
"version": "v1.0.0",
"args": ["kernel-args"]
}
}
}`
req, err := http.NewRequest(http.MethodPut, srv.URL+"/api/nodes/test", bytes.NewBuffer([]byte(testNode)))
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, `{"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}`, string(body))
})
t.Run("read all nodes", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/nodes", 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())
assert.JSONEq(t, `{"node1": {}, "test": {"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}}`, string(body))
})
t.Run("get one specific node", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/nodes/test", 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())
assert.JSONEq(t, `{"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}`, string(body))
})
t.Run("update the node", func(t *testing.T) {
updateNode := `{
"node":{
"kernel": {
"version": "v1.0.1-newversion"
}
}
}`
req, err := http.NewRequest(http.MethodPatch, srv.URL+"/api/nodes/test", bytes.NewBuffer([]byte(updateNode)))
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, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
})
t.Run("get one specific node (again)", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/nodes/test", 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, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
})
t.Run("get one specific (raw) node", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/nodes/test/raw", 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, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
})
t.Run("test build all nodes overlays", func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/nodes/overlays/build", 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, `["node1", "test"]`, string(body))
})
t.Run("test build one node's overlays", func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/nodes/test/overlays/build", 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, `"test"`, string(body))
})
t.Run("test delete nodes", func(t *testing.T) {
req, err := http.NewRequest(http.MethodDelete, srv.URL+"/api/nodes/test", 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, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
})
}

View File

@@ -0,0 +1,234 @@
package api
import (
"context"
"fmt"
"net/url"
"os"
"path"
"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{}
if files, err := overlay.OverlayGetFiles(name); err == nil {
o.Files = files
}
o.Site = overlay.GetOverlay(name).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 !overlay.GetOverlay(input.Name).Exists() {
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"`
rendered bool
}
func (of *OverlayFile) FullPath() string {
return path.Join(overlay.GetOverlay(of.Overlay).Rootfs(), of.Path)
}
func (of *OverlayFile) Exists() bool {
return overlay.GetOverlay(of.Overlay).Exists() && util.IsFile(of.FullPath())
}
func (of *OverlayFile) readContents() (string, error) {
f, err := os.ReadFile(of.FullPath())
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
buffer, _, _, renderErr := overlay.RenderTemplateFile(of.FullPath(), tstruct)
if renderErr != nil {
return "", renderErr
}
return buffer.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)
newOverlay := overlay.GetSiteOverlay(input.Name)
if err := newOverlay.Create(); 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 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)
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)
}
}
*output = *NewOverlayResponse(input.Name)
overlay_ := overlay.GetSiteOverlay(input.Name)
if input.Force {
if err := os.RemoveAll(overlay_.Path()); err != nil {
return err
}
} else {
if err := os.Remove(overlay_.Path()); err != nil {
return err
}
}
return nil
})
u.SetTitle("Delete an overlay")
u.SetDescription("Delete an overlay.")
u.SetTags("Overlay")
return u
}

View File

@@ -0,0 +1,121 @@
package api
import (
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/warewulf/warewulf/internal/pkg/testenv"
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
)
func TestOverlayAPI(t *testing.T) {
warewulfd.SetNoDaemon()
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("usr/share/warewulf/overlays/testoverlay/email.ww", `
{{ if .Tags.email }}eMail: {{ .Tags.email }}{{else}} noMail{{- end }}
`)
allowedNets := []net.IPNet{
{
IP: net.IPv4(127, 0, 0, 0),
Mask: net.CIDRMask(8, 32),
},
}
srv := httptest.NewServer(Handler(nil, allowedNets))
defer srv.Close()
t.Run("get all overlays", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/overlays", 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())
assert.JSONEq(t, `{"testoverlay":{"files":["/email.ww"], "site":false}}`, string(body))
})
t.Run("get one specific overlay", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/overlays/testoverlay", 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())
assert.JSONEq(t, `{"files":["/email.ww"], "site":false}`, string(body))
})
t.Run("get overlay file", func(t *testing.T) {
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())
assert.JSONEq(t, `{"overlay":"testoverlay","path":"email.ww","contents":"\n{{ if .Tags.email }}eMail: {{ .Tags.email }}{{else}} noMail{{- end }}\n"}`, string(body))
})
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)
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("get all overlays", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/overlays", 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, `{"test":{"files":null, "site":true},"testoverlay":{"files":["/email.ww"], "site":false}}`, 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)
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))
})
}

View File

@@ -0,0 +1,193 @@
package api
import (
"context"
"fmt"
"dario.cat/mergo"
"github.com/swaggest/usecase"
"github.com/swaggest/usecase/status"
"github.com/warewulf/warewulf/internal/pkg/image"
"github.com/warewulf/warewulf/internal/pkg/node"
"github.com/warewulf/warewulf/internal/pkg/overlay"
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
func getProfiles() usecase.Interactor {
u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, output *map[string]*node.Profile) error {
wwlog.Debug("api.getProfiles()")
if registry, err := node.New(); err != nil {
return err
} else {
*output = registry.NodeProfiles
return nil
}
})
u.SetTitle("Get profiles")
u.SetDescription("Get all node profiles.")
u.SetTags("Profile")
return u
}
func getProfileByID() usecase.Interactor {
type getProfileByIDInput struct {
ID string `path:"id" required:"true" description:"ID of profile to get"`
}
u := usecase.NewInteractor(func(ctx context.Context, input getProfileByIDInput, output *node.Profile) error {
wwlog.Debug("api.getProfileByID(ID:%v)", input.ID)
if registry, err := node.New(); err != nil {
return err
} else {
if profile, err := registry.GetProfile(input.ID); err != nil {
return status.Wrap(fmt.Errorf("profile not found: %v (%v)", input.ID, err), status.NotFound)
} else {
*output = profile
return nil
}
}
})
u.SetTitle("Get a profile")
u.SetDescription("Get a node profile by its ID.")
u.SetTags("Profile")
u.SetExpectedErrors(status.NotFound)
return u
}
func addProfile() usecase.Interactor {
type addProfileInput struct {
ID string `path:"id" required:"true" description:"ID of profile to add"`
Profile node.Profile `json:"profile" required:"true" description:"Field values in JSON format for added profile"`
}
u := usecase.NewInteractor(func(ctx context.Context, input addProfileInput, output *node.Profile) error {
wwlog.Debug("api.addProfile(ID:%v, Profile:%+v)", input.ID, input.Profile)
if registry, err := node.New(); err != nil {
return err
} else {
for _, profile := range input.Profile.Profiles {
if _, ok := registry.NodeProfiles[profile]; !ok {
return status.Wrap(fmt.Errorf("profile '%s' does not exist", profile), status.InvalidArgument)
}
}
if input.Profile.ImageName != "" && !image.ValidSource(input.Profile.ImageName) {
return status.Wrap(fmt.Errorf("image '%s' does not exist", input.Profile.ImageName), status.InvalidArgument)
}
for _, overlay_ := range input.Profile.SystemOverlay {
if !overlay.GetOverlay(overlay_).Exists() {
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
}
}
for _, overlay_ := range input.Profile.RuntimeOverlay {
if !overlay.GetOverlay(overlay_).Exists() {
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
}
}
registry.NodeProfiles[input.ID] = &input.Profile
if err := registry.Persist(); err != nil {
return err
}
warewulfd.Reload()
*output = *(registry.NodeProfiles[input.ID])
return nil
}
})
u.SetTitle("Add a profile")
u.SetDescription("Add a new node profile.")
u.SetTags("Profile")
return u
}
func updateProfile() usecase.Interactor {
type updateProfileInput struct {
ID string `path:"id" required:"true" description:"ID of profile to update"`
Profile node.Profile `json:"profile" required:"true" description:"Field values in JSON format to update on profile"`
}
u := usecase.NewInteractor(func(ctx context.Context, input updateProfileInput, output *node.Profile) error {
wwlog.Debug("api.updateProfile(ID:%v, Profile:%+v)", input.ID, input.Profile)
if registry, err := node.New(); err != nil {
return err
} else {
for _, profile := range input.Profile.Profiles {
if _, ok := registry.NodeProfiles[profile]; !ok {
return status.Wrap(fmt.Errorf("profile '%s' does not exist", profile), status.InvalidArgument)
}
}
if input.Profile.ImageName != "" && !image.ValidSource(input.Profile.ImageName) {
return status.Wrap(fmt.Errorf("image '%s' does not exist", input.Profile.ImageName), status.InvalidArgument)
}
for _, overlay_ := range input.Profile.SystemOverlay {
if !overlay.GetOverlay(overlay_).Exists() {
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
}
}
for _, overlay_ := range input.Profile.RuntimeOverlay {
if !overlay.GetOverlay(overlay_).Exists() {
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
}
}
if profilePtr, err := registry.GetProfilePtr(input.ID); err != nil {
return status.Wrap(err, status.NotFound)
} else {
if err := mergo.MergeWithOverwrite(profilePtr, &input.Profile); err != nil {
return err
}
if err := registry.Persist(); err != nil {
return err
}
warewulfd.Reload()
*output = *profilePtr
return nil
}
}
})
u.SetTitle("Update a profile")
u.SetDescription("Update an existing node profile.")
u.SetTags("Profile")
return u
}
func deleteProfile() usecase.Interactor {
type deleteProfileInput struct {
ID string `path:"id" required:"true" description:"ID of profile to delete"`
}
u := usecase.NewInteractor(func(ctx context.Context, input deleteProfileInput, output *node.Profile) error {
wwlog.Debug("api.deleteProfile(ID:%v)", input.ID)
if registry, err := node.New(); err != nil {
return err
} else {
if profile, ok := registry.NodeProfiles[input.ID]; ok {
*output = *profile
}
nodesCount := len(registry.ListNodesUsingProfile(input.ID))
profilesCount := len(registry.ListProfilesUsingProfile(input.ID))
if nodesCount > 0 || profilesCount > 0 {
return status.Wrap(fmt.Errorf(
"profile '%s' is in use by %v nodes and %v profiles", input.ID, nodesCount, profilesCount),
status.InvalidArgument)
}
if err := registry.DelProfile(input.ID); err != nil {
return err
}
if err := registry.Persist(); err != nil {
return err
}
warewulfd.Reload()
return nil
}
})
u.SetTitle("Delete a profile")
u.SetDescription("Delete an existing node profile.")
u.SetTags("Profile")
return u
}

View File

@@ -0,0 +1,122 @@
package api
import (
"bytes"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/warewulf/warewulf/internal/pkg/testenv"
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
)
func TestProfileAPI(t *testing.T) {
warewulfd.SetNoDaemon()
env := testenv.New(t)
defer env.RemoveAll()
allowedNets := []net.IPNet{
{
IP: net.IPv4(127, 0, 0, 0),
Mask: net.CIDRMask(8, 32),
},
}
srv := httptest.NewServer(Handler(nil, allowedNets))
defer srv.Close()
t.Run("get all profiles", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/profiles", nil)
assert.NoError(t, err)
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
assert.JSONEq(t, `{"default": {}}`, string(body))
})
t.Run("add a new profile", func(t *testing.T) {
testProfile := `{"profile": {"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}}`
req, err := http.NewRequest(http.MethodPut, srv.URL+"/api/profiles/test", bytes.NewBuffer([]byte(testProfile)))
assert.NoError(t, err)
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
assert.JSONEq(t, `{"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}`, string(body))
})
t.Run("re-read all profiles", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/profiles", nil)
assert.NoError(t, err)
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
assert.JSONEq(t, `{"default": {}, "test": {"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}}`, string(body))
})
t.Run("get one specific profile (that was just added)", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/profiles/test", nil)
assert.NoError(t, err)
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
assert.JSONEq(t, `{"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}`, string(body))
})
t.Run("update the profile", func(t *testing.T) {
updateProfile := `{"profile": {"kernel": {"version": "v1.0.1-newversion"}}}`
req, err := http.NewRequest(http.MethodPatch, srv.URL+"/api/profiles/test", bytes.NewBuffer([]byte(updateProfile)))
assert.NoError(t, err)
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
assert.JSONEq(t, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
})
t.Run("get one specific profile (that was just updated)", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/profiles/test", nil)
assert.NoError(t, err)
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
assert.JSONEq(t, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
})
t.Run("test delete a profile", func(t *testing.T) {
req, err := http.NewRequest(http.MethodDelete, srv.URL+"/api/profiles/test", nil)
assert.NoError(t, err)
resp, err := http.DefaultTransport.RoundTrip(req)
assert.NoError(t, err)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, resp.Body.Close())
assert.NoError(t, err)
assert.JSONEq(t, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
})
}

View File

@@ -49,8 +49,6 @@ func DaemonInitLogging() error {
if err == nil {
wwlog.SetLogLevel(level)
}
} else {
wwlog.SetLogLevel(wwlog.INFO)
}
loginit = true

View File

@@ -106,3 +106,13 @@ func GetNodeOrSetDiscoverable(hwaddr string) (node.Node, error) {
// return the discovered node
return db.yml.GetNode(nodeFound.Id())
}
func Reload() {
if err := LoadNodeDB(); err != nil {
wwlog.Error("Could not load node DB: %s", err)
}
if err := LoadNodeStatus(); err != nil {
wwlog.Error("Could not prepopulate node status DB: %s", err)
}
}

View File

@@ -0,0 +1,90 @@
package server
import (
"fmt"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/util"
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
"github.com/warewulf/warewulf/internal/pkg/warewulfd/api"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
// TODO: https://github.com/danderson/netboot/blob/master/pixiecore/dhcp.go
// TODO: https://github.com/pin/tftp
/*
wrapper type for the server mux as shim requests http://efiboot//grub.efi
which is filtered out by http to `301 Moved Permanently` what
shim.efi can't handle. So filter out `//` before they hit go/http.
Makes go/http more to behave like apache
*/
type slashFix struct {
mux http.Handler
}
/*
Filter out the '//'
*/
func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.Replace(r.URL.Path, "//", "/", -1)
h.mux.ServeHTTP(w, r)
}
func defaultHandler() *slashFix {
var wwHandler http.ServeMux
wwHandler.HandleFunc("/provision/", warewulfd.ProvisionSend)
wwHandler.HandleFunc("/ipxe/", warewulfd.ProvisionSend)
wwHandler.HandleFunc("/efiboot/", warewulfd.ProvisionSend)
wwHandler.HandleFunc("/kernel/", warewulfd.ProvisionSend)
wwHandler.HandleFunc("/container/", warewulfd.ProvisionSend)
wwHandler.HandleFunc("/overlay-system/", warewulfd.ProvisionSend)
wwHandler.HandleFunc("/overlay-runtime/", warewulfd.ProvisionSend)
wwHandler.HandleFunc("/overlay-file/", warewulfd.OverlaySend)
wwHandler.HandleFunc("/status", warewulfd.StatusSend)
return &slashFix{&wwHandler}
}
func RunServer() error {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP)
go func() {
for range c {
wwlog.Info("Received SIGHUP, reloading...")
warewulfd.Reload()
}
}()
warewulfd.Reload()
conf := warewulfconf.Get()
daemonPort := conf.Warewulf.Port
auth := warewulfconf.NewAuthentication()
if util.IsFile(conf.Paths.AuthenticationConf()) {
if err := auth.Read(conf.Paths.AuthenticationConf()); err != nil {
wwlog.Warn("%w\n", err)
}
}
apiHandler := api.Handler(auth, conf.API.AllowedIPNets())
defaultHandler := defaultHandler()
dispatchHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api") && conf.API != nil && conf.API.Enabled() {
apiHandler.ServeHTTP(w, r)
} else {
defaultHandler.ServeHTTP(w, r)
}
})
if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), dispatchHandler); err != nil {
return fmt.Errorf("could not start listening service: %w", err)
}
return nil
}

View File

@@ -1,89 +0,0 @@
package warewulfd
import (
"fmt"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
// TODO: https://github.com/danderson/netboot/blob/master/pixiecore/dhcp.go
// TODO: https://github.com/pin/tftp
/*
wrapper type for the server mux as shim requests http://efiboot//grub.efi
which is filtered out by http to `301 Moved Permanently` what
shim.efi can't handle. So filter out `//` before they hit go/http.
Makes go/http more to behave like apache
*/
type slashFix struct {
mux http.Handler
}
/*
Filter out the '//'
*/
func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.Replace(r.URL.Path, "//", "/", -1)
h.mux.ServeHTTP(w, r)
}
func RunServer() error {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP)
go func() {
for range c {
wwlog.Warn("Received SIGHUP, reloading...")
err := LoadNodeDB()
if err != nil {
wwlog.Error("Could not load node DB: %s", err)
}
err = LoadNodeStatus()
if err != nil {
wwlog.Error("Could not prepopulate node status DB: %s", err)
}
}
}()
err := LoadNodeDB()
if err != nil {
wwlog.Error("Could not load database: %s", err)
}
err = LoadNodeStatus()
if err != nil {
wwlog.Error("Could not prepopulate node status DB: %s", err)
}
if err != nil {
wwlog.Warn("couldn't copy default shim: %s", err)
}
var wwHandler http.ServeMux
wwHandler.HandleFunc("/provision/", ProvisionSend)
wwHandler.HandleFunc("/ipxe/", ProvisionSend)
wwHandler.HandleFunc("/efiboot/", ProvisionSend)
wwHandler.HandleFunc("/kernel/", ProvisionSend)
wwHandler.HandleFunc("/image/", ProvisionSend)
wwHandler.HandleFunc("/overlay-system/", ProvisionSend)
wwHandler.HandleFunc("/overlay-runtime/", ProvisionSend)
wwHandler.HandleFunc("/overlay-file/", OverlaySend)
wwHandler.HandleFunc("/status", StatusSend)
conf := warewulfconf.Get()
daemonPort := conf.Warewulf.Port
err = http.ListenAndServe(":"+strconv.Itoa(daemonPort), &slashFix{&wwHandler})
if err != nil {
return fmt.Errorf("could not start listening service: %w", err)
}
return nil
}