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:
80
internal/pkg/warewulfd/api/api.go
Normal file
80
internal/pkg/warewulfd/api/api.go
Normal 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
|
||||
}
|
||||
56
internal/pkg/warewulfd/api/authentication.go
Normal file
56
internal/pkg/warewulfd/api/authentication.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
213
internal/pkg/warewulfd/api/image.go
Normal file
213
internal/pkg/warewulfd/api/image.go
Normal 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
|
||||
}
|
||||
144
internal/pkg/warewulfd/api/image_test.go
Normal file
144
internal/pkg/warewulfd/api/image_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
303
internal/pkg/warewulfd/api/node.go
Normal file
303
internal/pkg/warewulfd/api/node.go
Normal 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
|
||||
}
|
||||
176
internal/pkg/warewulfd/api/node_test.go
Normal file
176
internal/pkg/warewulfd/api/node_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
234
internal/pkg/warewulfd/api/overlay.go
Normal file
234
internal/pkg/warewulfd/api/overlay.go
Normal 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
|
||||
}
|
||||
121
internal/pkg/warewulfd/api/overlay_test.go
Normal file
121
internal/pkg/warewulfd/api/overlay_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
193
internal/pkg/warewulfd/api/profile.go
Normal file
193
internal/pkg/warewulfd/api/profile.go
Normal 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
|
||||
}
|
||||
122
internal/pkg/warewulfd/api/profile_test.go
Normal file
122
internal/pkg/warewulfd/api/profile_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
@@ -49,8 +49,6 @@ func DaemonInitLogging() error {
|
||||
if err == nil {
|
||||
wwlog.SetLogLevel(level)
|
||||
}
|
||||
} else {
|
||||
wwlog.SetLogLevel(wwlog.INFO)
|
||||
}
|
||||
|
||||
loginit = true
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
90
internal/pkg/warewulfd/server/server.go
Normal file
90
internal/pkg/warewulfd/server/server.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user