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

@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `wwctl node import --yes` to assume yes to confirmations. - Add `wwctl node import --yes` to assume yes to confirmations.
- Set an IPMI tag ``vlan`` to configure the vlan during ``ipmiwrite``. #1031 - Set an IPMI tag ``vlan`` to configure the vlan during ``ipmiwrite``. #1031
- Added net.ifnames=1 to default kernel argument list. #1820 - Added net.ifnames=1 to default kernel argument list. #1820
- Add a new OpenAPI v3 REST API to warewulfd at /api. #1588
### Fixed ### Fixed
@@ -31,12 +32,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fixed issue with importing new nodes from yaml. #1842 - Fixed issue with importing new nodes from yaml. #1842
- `wwctl upgrade nodes --replace-overlays` avoids applying overlays multiple times. #1823 - `wwctl upgrade nodes --replace-overlays` avoids applying overlays multiple times. #1823
- Fix a panic during `wwctl node list --ipmi` for nodes with no ipmi configuration. #1847 - Fix a panic during `wwctl node list --ipmi` for nodes with no ipmi configuration. #1847
- Allow `--verbose` and `--debug` to set log level for warewulfd. #1801
### Changed ### Changed
- Update GitHub actions to build aarch64 artifacts. - Update GitHub actions to build aarch64 artifacts.
- Explicitly enforce the number or arguments accepted by some `wwctl` subcommands. #1717 - Explicitly enforce the number or arguments accepted by some `wwctl` subcommands. #1717
- Renamed `wwctl node import --cvs` to `--csv`. - Renamed `wwctl node import --cvs` to `--csv`.
- Update JSON output to match YAML field names. #1588
- Update JSON output to omit empty values. #1588
### Removed ### Removed

View File

@@ -317,6 +317,12 @@ The dependencies and their licenses are as follows:
**License URL:** <https://github.com/stefanberger/go-pkcs11uri/blob/78284954bff6/LICENSE> **License URL:** <https://github.com/stefanberger/go-pkcs11uri/blob/78284954bff6/LICENSE>
## github.com/swaggest/swgui
**License:** Apache-2.0
**License URL:** <https://github.com/swaggest/swgui/blob/v1.8.2/LICENSE>
## github.com/vbatts/go-mtree/pkg/govis ## github.com/vbatts/go-mtree/pkg/govis
**License:** Apache-2.0 **License:** Apache-2.0
@@ -371,6 +377,12 @@ The dependencies and their licenses are as follows:
**License URL:** <https://github.com/grpc/grpc-go/blob/v1.70.0/LICENSE> **License URL:** <https://github.com/grpc/grpc-go/blob/v1.70.0/LICENSE>
## gopkg.in/yaml.v2
**License:** Apache-2.0
**License URL:** <https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE>
## github.com/moby/sys/capability ## github.com/moby/sys/capability
**License:** BSD-2-Clause **License:** BSD-2-Clause
@@ -467,6 +479,12 @@ The dependencies and their licenses are as follows:
**License URL:** <https://github.com/proglottis/gpgme/blob/v0.1.3/LICENSE> **License URL:** <https://github.com/proglottis/gpgme/blob/v0.1.3/LICENSE>
## github.com/santhosh-tekuri/jsonschema/v3
**License:** BSD-3-Clause
**License URL:** <https://github.com/santhosh-tekuri/jsonschema/blob/v3.1.0/LICENSE>
## github.com/spf13/pflag ## github.com/spf13/pflag
**License:** BSD-3-Clause **License:** BSD-3-Clause
@@ -629,6 +647,12 @@ The dependencies and their licenses are as follows:
**License URL:** <https://github.com/felixge/httpsnoop/blob/v1.0.4/LICENSE.txt> **License URL:** <https://github.com/felixge/httpsnoop/blob/v1.0.4/LICENSE.txt>
## github.com/go-chi/chi/v5
**License:** MIT
**License URL:** <https://github.com/go-chi/chi/blob/v5.2.1/LICENSE>
## github.com/huandu/xstrings ## github.com/huandu/xstrings
**License:** MIT **License:** MIT
@@ -749,6 +773,42 @@ The dependencies and their licenses are as follows:
**License URL:** <https://github.com/stretchr/testify/blob/v1.10.0/LICENSE> **License URL:** <https://github.com/stretchr/testify/blob/v1.10.0/LICENSE>
## github.com/swaggest/form/v5
**License:** MIT
**License URL:** <https://github.com/swaggest/form/blob/v5.1.1/LICENSE>
## github.com/swaggest/jsonschema-go
**License:** MIT
**License URL:** <https://github.com/swaggest/jsonschema-go/blob/v0.3.73/LICENSE>
## github.com/swaggest/openapi-go
**License:** MIT
**License URL:** <https://github.com/swaggest/openapi-go/blob/v0.2.55/LICENSE>
## github.com/swaggest/refl
**License:** MIT
**License URL:** <https://github.com/swaggest/refl/blob/v1.3.0/LICENSE>
## github.com/swaggest/rest
**License:** MIT
**License URL:** <https://github.com/swaggest/rest/blob/v0.2.73/LICENSE>
## github.com/swaggest/usecase
**License:** MIT
**License URL:** <https://github.com/swaggest/usecase/blob/v1.3.1/LICENSE>
## github.com/titanous/rocacheck ## github.com/titanous/rocacheck
**License:** MIT **License:** MIT
@@ -761,6 +821,12 @@ The dependencies and their licenses are as follows:
**License URL:** <https://github.com/urfave/cli/blob/v1.22.16/LICENSE> **License URL:** <https://github.com/urfave/cli/blob/v1.22.16/LICENSE>
## github.com/vearutop/statigz
**License:** MIT
**License URL:** <https://github.com/vearutop/statigz/blob/v1.4.0/LICENSE>
## github.com/vincent-petithory/dataurl ## github.com/vincent-petithory/dataurl
**License:** MIT **License:** MIT

View File

@@ -20,9 +20,6 @@ include Tools.mk
.PHONY: build .PHONY: build
build: wwctl wwclient etc/bash_completion.d/wwctl ## Build the Warewulf binaries build: wwctl wwclient etc/bash_completion.d/wwctl ## Build the Warewulf binaries
.PHONY: api
api: wwapid wwapic wwapird
.PHONY: docs .PHONY: docs
docs: man_pages reference ## Build the documentation docs: man_pages reference ## Build the documentation
@@ -48,12 +45,6 @@ config: $(config)
$(config): Defaults.mk $(config): Defaults.mk
apiconfig = etc/wwapic.conf \
etc/wwapid.conf \
etc/wwapird.conf
.PHONY: apiconfig
apiconfig: $(apiconfig)
%: %.in %: %.in
sed -ne "$(foreach V,$(VARLIST),s,@$V@,$(strip $($V)),g;)p" $@.in >$@ sed -ne "$(foreach V,$(VARLIST),s,@$V@,$(strip $($V)),g;)p" $@.in >$@
@@ -63,15 +54,6 @@ wwctl: $(config) $(call godeps,cmd/wwctl/main.go)
wwclient: $(config) $(call godeps,cmd/wwclient/main.go) wwclient: $(config) $(call godeps,cmd/wwclient/main.go)
CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -ldflags "-extldflags -static" -o wwclient cmd/wwclient/main.go CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -ldflags "-extldflags -static" -o wwclient cmd/wwclient/main.go
wwapid: $(config) $(apiconfig) $(call godeps,internal/app/api/wwapid/wwapid.go)
go build -o ./wwapid internal/app/api/wwapid/wwapid.go
wwapic: $(config) $(apiconfig) $(call godeps,internal/app/api/wwapic/wwapic.go)
go build -o ./wwapic internal/app/api/wwapic/wwapic.go
wwapird: $(config) $(apiconfig) $(call godeps,internal/app/api/wwapird/wwapird.go)
go build -o ./wwapird internal/app/api/wwapird/wwapird.go
.PHONY: man_pages .PHONY: man_pages
man_pages: wwctl $(wildcard docs/man/man5/*.5) man_pages: wwctl $(wildcard docs/man/man5/*.5)
mkdir -p docs/man/man1 mkdir -p docs/man/man1
@@ -162,7 +144,6 @@ cleanmake:
.PHONY: cleanbin .PHONY: cleanbin
cleanbin: cleanbin:
rm -f wwapi{c,d,rd}
rm -f wwclient rm -f wwclient
rm -f wwctl rm -f wwctl
rm -f update_configuration rm -f update_configuration
@@ -207,6 +188,7 @@ install: build docs ## Install Warewulf from source
# wwctl genconfig to get the compiled in paths to warewulf.conf # wwctl genconfig to get the compiled in paths to warewulf.conf
install -d -m 0755 $(DESTDIR)$(DATADIR)/warewulf/bmc install -d -m 0755 $(DESTDIR)$(DATADIR)/warewulf/bmc
test -f $(DESTDIR)$(WWCONFIGDIR)/warewulf.conf || ./wwctl --warewulfconf etc/warewulf.conf genconfig warewulfconf print> $(DESTDIR)$(WWCONFIGDIR)/warewulf.conf test -f $(DESTDIR)$(WWCONFIGDIR)/warewulf.conf || ./wwctl --warewulfconf etc/warewulf.conf genconfig warewulfconf print> $(DESTDIR)$(WWCONFIGDIR)/warewulf.conf
test -f $(DESTDIR)$(WWCONFIGDIR)/auth.conf || install -m 0600 etc/auth.conf $(DESTDIR)$(WWCONFIGDIR)
test -f $(DESTDIR)$(WWCONFIGDIR)/nodes.conf || install -m 0644 etc/nodes.conf $(DESTDIR)$(WWCONFIGDIR) test -f $(DESTDIR)$(WWCONFIGDIR)/nodes.conf || install -m 0644 etc/nodes.conf $(DESTDIR)$(WWCONFIGDIR)
for f in etc/examples/*.ww; do install -m 0644 $$f $(DESTDIR)$(WWCONFIGDIR)/examples/; done for f in etc/examples/*.ww; do install -m 0644 $$f $(DESTDIR)$(WWCONFIGDIR)/examples/; done
for f in etc/ipxe/*.ipxe; do install -m 0644 $$f $(DESTDIR)$(WWCONFIGDIR)/ipxe/; done for f in etc/ipxe/*.ipxe; do install -m 0644 $$f $(DESTDIR)$(WWCONFIGDIR)/ipxe/; done
@@ -236,15 +218,6 @@ install: build docs ## Install Warewulf from source
install -m 0644 dracut/modules.d/90wwinit/*.sh $(DESTDIR)$(DRACUTMODDIR)/90wwinit install -m 0644 dracut/modules.d/90wwinit/*.sh $(DESTDIR)$(DRACUTMODDIR)/90wwinit
install -D -m 0644 include/sos/warewulf.py $(DESTDIR)$(SOSPLUGINS)/warewulf.py install -D -m 0644 include/sos/warewulf.py $(DESTDIR)$(SOSPLUGINS)/warewulf.py
.PHONY: installapi
installapi:
install -m 0755 wwapic $(DESTDIR)$(BINDIR)
install -m 0755 wwapid $(DESTDIR)$(BINDIR)
install -m 0755 wwapird $(DESTDIR)$(BINDIR)
test -f $(DESTDIR)$(WWCONFIGDIR)/wwapic.conf || install -m 0644 etc/wwapic.conf $(DESTDIR)$(WWCONFIGDIR)
test -f $(DESTDIR)$(WWCONFIGDIR)/wwapid.conf || install -m 0644 etc/wwapid.conf $(DESTDIR)$(WWCONFIGDIR)
test -f $(DESTDIR)$(WWCONFIGDIR)/wwapird.conf || install -m 0644 etc/wwapird.conf $(DESTDIR)$(WWCONFIGDIR)
.PHONY: init .PHONY: init
init: init:
systemctl daemon-reload systemctl daemon-reload
@@ -255,9 +228,6 @@ ifndef OFFLINE_BUILD
wwctl: vendor wwctl: vendor
wwclient: vendor wwclient: vendor
update_configuration: vendor update_configuration: vendor
wwapid: vendor
wwapic: vendor
wwapird: vendor
dist: vendor dist: vendor
lint: $(GOLANGCI_LINT) lint: $(GOLANGCI_LINT)

3
etc/auth.conf Normal file
View File

@@ -0,0 +1,3 @@
users:
- name: admin
password hash: "" # generate a password hash with `mkpasswd --method=bcrypt`

13
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/coreos/vcontext v0.0.0-20230201181013-d72178a18687 github.com/coreos/vcontext v0.0.0-20230201181013-d72178a18687
github.com/creasty/defaults v1.8.0 github.com/creasty/defaults v1.8.0
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
github.com/go-chi/chi/v5 v5.2.1
github.com/golang/glog v1.2.4 github.com/golang/glog v1.2.4
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1
@@ -27,7 +28,12 @@ require (
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/swaggest/openapi-go v0.2.55
github.com/swaggest/rest v0.2.73
github.com/swaggest/swgui v1.8.2
github.com/swaggest/usecase v1.3.1
github.com/talos-systems/go-smbios v0.1.1 github.com/talos-systems/go-smbios v0.1.1
golang.org/x/crypto v0.32.0
golang.org/x/sys v0.29.0 golang.org/x/sys v0.29.0
golang.org/x/term v0.28.0 golang.org/x/term v0.28.0
google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489
@@ -115,6 +121,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rootless-containers/proto v0.1.0 // indirect github.com/rootless-containers/proto v0.1.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/sigstore/fulcio v1.4.5 // indirect github.com/sigstore/fulcio v1.4.5 // indirect
@@ -124,12 +131,16 @@ require (
github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect
github.com/swaggest/form/v5 v5.1.1 // indirect
github.com/swaggest/jsonschema-go v0.3.73 // indirect
github.com/swaggest/refl v1.3.0 // indirect
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect github.com/ulikunitz/xz v0.5.12 // indirect
github.com/urfave/cli v1.22.16 // indirect github.com/urfave/cli v1.22.16 // indirect
github.com/vbatts/go-mtree v0.5.0 // indirect github.com/vbatts/go-mtree v0.5.0 // indirect
github.com/vbatts/tar-split v0.11.7 // indirect github.com/vbatts/tar-split v0.11.7 // indirect
github.com/vbauerster/mpb/v8 v8.7.5 // indirect github.com/vbauerster/mpb/v8 v8.7.5 // indirect
github.com/vearutop/statigz v1.4.0 // indirect
github.com/vincent-petithory/dataurl v1.0.0 // indirect github.com/vincent-petithory/dataurl v1.0.0 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect go.mongodb.org/mongo-driver v1.14.0 // indirect
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect
@@ -137,10 +148,10 @@ require (
go.opentelemetry.io/otel v1.32.0 // indirect go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.32.0 // indirect go.opentelemetry.io/otel/trace v1.32.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect
golang.org/x/net v0.33.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
) )

38
go.sum
View File

@@ -20,6 +20,8 @@ github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1o
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/apex/log v1.4.0 h1:jYWeNt9kWJOf1ifht8UjsCQ00eiPnFrUzCBCiiJMw/g= github.com/apex/log v1.4.0 h1:jYWeNt9kWJOf1ifht8UjsCQ00eiPnFrUzCBCiiJMw/g=
github.com/apex/log v1.4.0/go.mod h1:UMNC4vQNC7hb5gyr47r18ylK1n34rV7GO+gb0wpXvcE= github.com/apex/log v1.4.0/go.mod h1:UMNC4vQNC7hb5gyr47r18ylK1n34rV7GO+gb0wpXvcE=
github.com/apex/logs v0.0.7/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo= github.com/apex/logs v0.0.7/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo=
@@ -31,6 +33,11 @@ github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN
github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
github.com/bool64/dev v0.2.38 h1:C5H9wkx/BhTYRfV14X90iIQKpSuhzsG+OHQvWdQ5YQ4=
github.com/bool64/dev v0.2.38/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs=
github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM=
github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -117,6 +124,8 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
@@ -188,6 +197,8 @@ github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
@@ -315,9 +326,13 @@ github.com/rootless-containers/proto v0.1.0/go.mod h1:vgkUFZbQd0gcE/K/ZwtE4MYjZP
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 h1:levPcBfnazlA1CyCMC3asL/QLZkq9pa8tQZOH513zQw=
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0/go.mod h1:8kzK2TC0k0YjOForaAHdNEa7ik0fokNa2k30BKJ/W7Y=
github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA= github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA=
github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU= github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
@@ -358,6 +373,22 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ=
github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU=
github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY=
github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg=
github.com/swaggest/jsonschema-go v0.3.73 h1:gU1pBzF3pkZ1GDD3dRMdQoCjrA0sldJ+QcM7aSSPgvc=
github.com/swaggest/jsonschema-go v0.3.73/go.mod h1:qp+Ym2DIXHlHzch3HKz50gPf2wJhKOrAB/VYqLS2oJU=
github.com/swaggest/openapi-go v0.2.55 h1:PI9r7E8l0iKHriqQ6QxLbTjkZyUIHA8rlpzatYZff3c=
github.com/swaggest/openapi-go v0.2.55/go.mod h1:sTmhR2sTvauaRX45WdaqRyJVTfRQ5FxD4OPVym49BXM=
github.com/swaggest/refl v1.3.0 h1:PEUWIku+ZznYfsoyheF97ypSduvMApYyGkYF3nabS0I=
github.com/swaggest/refl v1.3.0/go.mod h1:3Ujvbmh1pfSbDYjC6JGG7nMgPvpG0ehQL4iNonnLNbg=
github.com/swaggest/rest v0.2.73 h1:CFippm6JOx9OknBSm9VVSWFrDQq98RV/11a73GJTwxM=
github.com/swaggest/rest v0.2.73/go.mod h1:oXW3+1intYTcTxrZH5snFgme+mud4Qy6iTFY34pqATg=
github.com/swaggest/swgui v1.8.2 h1:JGpRCLGLZ7EqTwHsBEOo//kx8CM7Rv3RchgvfNpB+6E=
github.com/swaggest/swgui v1.8.2/go.mod h1:nkzGeyMfq5FstGGNJKr1LORvM4RdsjTmvWvqvyZeDDc=
github.com/swaggest/usecase v1.3.1 h1:JdKV30MTSsDxAXxkldLNcEn8O2uf565khyo6gr5sS+w=
github.com/swaggest/usecase v1.3.1/go.mod h1:cae3lDd5VDmM36OQcOOOdAlEDg40TiQYIp99S9ejWqA=
github.com/talos-systems/go-smbios v0.1.1 h1:Au6obB/Pp0i0JHhvPlzONk5aoNseosO2BUsmvWWi7y8= github.com/talos-systems/go-smbios v0.1.1 h1:Au6obB/Pp0i0JHhvPlzONk5aoNseosO2BUsmvWWi7y8=
github.com/talos-systems/go-smbios v0.1.1/go.mod h1:vk76naUSZaWE8Z95wbDn51FgH0goECM4oK3KY2hYSMU= github.com/talos-systems/go-smbios v0.1.1/go.mod h1:vk76naUSZaWE8Z95wbDn51FgH0goECM4oK3KY2hYSMU=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
@@ -379,6 +410,8 @@ github.com/vbatts/tar-split v0.11.7 h1:ixZ93pO/GmvaZw4Vq9OwmfZK/kc2zKdPfu0B+gYqs
github.com/vbatts/tar-split v0.11.7/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vbatts/tar-split v0.11.7/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
github.com/vbauerster/mpb/v8 v8.7.5 h1:hUF3zaNsuaBBwzEFoCvfuX3cpesQXZC0Phm/JcHZQ+c= github.com/vbauerster/mpb/v8 v8.7.5 h1:hUF3zaNsuaBBwzEFoCvfuX3cpesQXZC0Phm/JcHZQ+c=
github.com/vbauerster/mpb/v8 v8.7.5/go.mod h1:bRCnR7K+mj5WXKsy0NWB6Or+wctYGvVwKn6huwvxKa0= github.com/vbauerster/mpb/v8 v8.7.5/go.mod h1:bRCnR7K+mj5WXKsy0NWB6Or+wctYGvVwKn6huwvxKa0=
github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU=
github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE=
github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
@@ -387,6 +420,10 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
@@ -521,6 +558,7 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -459,27 +459,9 @@ nodes:
stdout: ` stdout: `
{ {
"n01": { "n01": {
"Discoverable": "", "profiles": [
"AssetKey": "",
"Profiles": [
"default" "default"
], ]
"Comment": "",
"ClusterName": "",
"ImageName": "",
"Ipxe": "",
"RuntimeOverlay": null,
"SystemOverlay": null,
"Kernel": null,
"Ipmi": null,
"Init": "",
"Root": "",
"NetDevs": null,
"Tags": null,
"PrimaryNetDev": "",
"Disks": null,
"FileSystems": null,
"Resources": null
} }
} }
`, `,
@@ -498,50 +480,14 @@ nodes:
stdout: ` stdout: `
{ {
"n01": { "n01": {
"Discoverable": "", "profiles": [
"AssetKey": "",
"Profiles": [
"default" "default"
], ]
"Comment": "",
"ClusterName": "",
"ImageName": "",
"Ipxe": "",
"RuntimeOverlay": null,
"SystemOverlay": null,
"Kernel": null,
"Ipmi": null,
"Init": "",
"Root": "",
"NetDevs": null,
"Tags": null,
"PrimaryNetDev": "",
"Disks": null,
"FileSystems": null,
"Resources": null
}, },
"n02": { "n02": {
"Discoverable": "", "profiles": [
"AssetKey": "",
"Profiles": [
"default" "default"
], ]
"Comment": "",
"ClusterName": "",
"ImageName": "",
"Ipxe": "",
"RuntimeOverlay": null,
"SystemOverlay": null,
"Kernel": null,
"Ipmi": null,
"Init": "",
"Root": "",
"NetDevs": null,
"Tags": null,
"PrimaryNetDev": "",
"Disks": null,
"FileSystems": null,
"Resources": null
} }
} }
`, `,

View File

@@ -153,25 +153,7 @@ nodes:
args: []string{"-j"}, args: []string{"-j"},
output: ` output: `
{ {
"default": { "default": {}
"Profiles": null,
"Comment": "",
"ClusterName": "",
"ImageName": "",
"Ipxe": "",
"RuntimeOverlay": null,
"SystemOverlay": null,
"Kernel": null,
"Ipmi": null,
"Init": "",
"Root": "",
"NetDevs": null,
"Tags": null,
"PrimaryNetDev": "",
"Disks": null,
"FileSystems": null,
"Resources": null
}
} }
`, `,
inDb: ` inDb: `
@@ -205,44 +187,8 @@ nodes:
args: []string{"-j"}, args: []string{"-j"},
output: ` output: `
{ {
"default": { "default": {},
"Profiles": null, "test": {}
"Comment": "",
"ClusterName": "",
"ImageName": "",
"Ipxe": "",
"RuntimeOverlay": null,
"SystemOverlay": null,
"Kernel": null,
"Ipmi": null,
"Init": "",
"Root": "",
"NetDevs": null,
"Tags": null,
"PrimaryNetDev": "",
"Disks": null,
"FileSystems": null,
"Resources": null
},
"test": {
"Profiles": null,
"Comment": "",
"ClusterName": "",
"ImageName": "",
"Ipxe": "",
"RuntimeOverlay": null,
"SystemOverlay": null,
"Kernel": null,
"Ipmi": null,
"Init": "",
"Root": "",
"NetDevs": null,
"Tags": null,
"PrimaryNetDev": "",
"Disks": null,
"FileSystems": null,
"Resources": null
}
} }
`, `,
inDb: ` inDb: `

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warewulf/warewulf/internal/app/wwctl/completions" "github.com/warewulf/warewulf/internal/app/wwctl/completions"
"github.com/warewulf/warewulf/internal/pkg/warewulfd" "github.com/warewulf/warewulf/internal/pkg/warewulfd"
"github.com/warewulf/warewulf/internal/pkg/warewulfd/server"
) )
var ( var (
@@ -31,7 +32,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
if err := warewulfd.DaemonInitLogging(); err != nil { if err := warewulfd.DaemonInitLogging(); err != nil {
return fmt.Errorf("failed to configure logging: %w", err) return fmt.Errorf("failed to configure logging: %w", err)
} }
if err := warewulfd.RunServer(); err != nil { if err := server.RunServer(); err != nil {
return fmt.Errorf("failed to start Warewulf server: %w", err) return fmt.Errorf("failed to start Warewulf server: %w", err)
} }
return nil return nil

View File

@@ -163,7 +163,7 @@ func ImageImport(cip *wwapiv1.ImageImportParameter) (imageName string, err error
} else if strings.HasPrefix(cip.Source, "docker://") || strings.HasPrefix(cip.Source, "docker-daemon://") || } else if strings.HasPrefix(cip.Source, "docker://") || strings.HasPrefix(cip.Source, "docker-daemon://") ||
strings.HasPrefix(cip.Source, "file://") || util.IsFile(cip.Source) { strings.HasPrefix(cip.Source, "file://") || util.IsFile(cip.Source) {
var sCtx *types.SystemContext var sCtx *types.SystemContext
sCtx, err = getSystemContext(cip.OciNoHttps, cip.OciUsername, cip.OciPassword, cip.Platform) sCtx, err = GetSystemContext(cip.OciNoHttps, cip.OciUsername, cip.OciPassword, cip.Platform)
if err != nil { if err != nil {
return return
} }
@@ -386,7 +386,7 @@ func ImageRename(crp *wwapiv1.ImageRenameParameter) (err error) {
} }
// create the system context and reading out environment variables // create the system context and reading out environment variables
func getSystemContext(noHttps bool, username string, password string, platform string) (sCtx *types.SystemContext, err error) { func GetSystemContext(noHttps bool, username string, password string, platform string) (sCtx *types.SystemContext, err error) {
sCtx = &types.SystemContext{} sCtx = &types.SystemContext{}
// only check env if noHttps wasn't set // only check env if noHttps wasn't set
if !noHttps { if !noHttps {

View File

@@ -0,0 +1,50 @@
package config
import (
"net"
"github.com/creasty/defaults"
)
type IPNet net.IPNet
func (n IPNet) MarshalText() ([]byte, error) {
ipnet := n.IPNet()
return []byte((&ipnet).String()), nil
}
func (n *IPNet) UnmarshalText(text []byte) error {
_, network, err := net.ParseCIDR(string(text))
if err != nil {
return err
}
*n = IPNet(*network)
return nil
}
func (n IPNet) IPNet() net.IPNet {
return net.IPNet(n)
}
type APIConf struct {
EnabledP *bool `yaml:"enabled,omitempty" default:"false"`
AllowedNets []IPNet `yaml:"allowed subnets,omitempty" default:"[\"127.0.0.0/8\", \"::1/128\"]"`
}
func (conf *APIConf) AllowedIPNets() (allowedIPNets []net.IPNet) {
for _, allowedIPNet := range conf.AllowedNets {
allowedIPNets = append(allowedIPNets, allowedIPNet.IPNet())
}
return allowedIPNets
}
func (conf *APIConf) Unmarshal(unmarshal func(interface{}) error) error {
if err := defaults.Set(conf); err != nil {
return err
}
return nil
}
func (conf APIConf) Enabled() bool {
return BoolP(conf.EnabledP)
}

View File

@@ -0,0 +1,72 @@
package config
import (
"fmt"
"os"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
type User struct {
Name string `json:"name" yaml:"name"`
PasswordHash string `json:"password hash" yaml:"password hash"`
}
type Authentication struct {
Users []User `json:"users" yaml:"users"`
userMap map[string]User `json:"-" yaml:"-"`
}
func NewAuthentication() *Authentication {
auth := new(Authentication)
auth.userMap = make(map[string]User)
return auth
}
func (auth *Authentication) ParseFromRaw(data []byte) error {
err := yaml.Unmarshal(data, auth)
if err != nil {
return err
}
if len(auth.Users) == 0 {
return fmt.Errorf("no record users")
}
for _, user := range auth.Users {
if _, ok := auth.userMap[user.Name]; ok {
return fmt.Errorf("duplicated user names")
}
auth.userMap[user.Name] = user
}
return nil
}
func (auth *Authentication) Read(confFileName string) error {
if data, err := os.ReadFile(confFileName); err != nil {
return err
} else {
if err := auth.ParseFromRaw(data); err != nil {
return err
}
}
return nil
}
var (
UnauthorizedError = errors.New("Unauthorized")
)
func (auth *Authentication) Authenticate(name, pass string) (*User, error) {
if user, ok := auth.userMap[name]; !ok {
return nil, UnauthorizedError
} else {
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(pass)); err != nil {
wwlog.Warn("%v\n", err)
return nil, UnauthorizedError
}
return &user, nil
}
}

View File

@@ -68,6 +68,10 @@ func (paths BuildConfig) NodesConf() string {
return path.Join(paths.Sysconfdir, "warewulf", "nodes.conf") return path.Join(paths.Sysconfdir, "warewulf", "nodes.conf")
} }
func (paths BuildConfig) AuthenticationConf() string {
return path.Join(paths.Sysconfdir, "warewulf", "auth.conf")
}
func (paths BuildConfig) OciBlobCachedir() string { func (paths BuildConfig) OciBlobCachedir() string {
return path.Join(paths.Cachedir, "warewulf") return path.Join(paths.Cachedir, "warewulf")
} }

View File

@@ -32,6 +32,7 @@ type WarewulfYaml struct {
Ipv6net string `yaml:"ipv6net,omitempty"` Ipv6net string `yaml:"ipv6net,omitempty"`
Fqdn string `yaml:"fqdn,omitempty"` Fqdn string `yaml:"fqdn,omitempty"`
Warewulf *WarewulfConf `yaml:"warewulf,omitempty"` Warewulf *WarewulfConf `yaml:"warewulf,omitempty"`
API *APIConf `yaml:"api,omitempty"`
DHCP *DHCPConf `yaml:"dhcp,omitempty"` DHCP *DHCPConf `yaml:"dhcp,omitempty"`
TFTP *TFTPConf `yaml:"tftp,omitempty"` TFTP *TFTPConf `yaml:"tftp,omitempty"`
NFS *NFSConf `yaml:"nfs,omitempty"` NFS *NFSConf `yaml:"nfs,omitempty"`
@@ -55,6 +56,7 @@ func New() *WarewulfYaml {
cachedConf.NFS = new(NFSConf) cachedConf.NFS = new(NFSConf)
cachedConf.SSH = new(SSHConf) cachedConf.SSH = new(SSHConf)
cachedConf.Paths = new(BuildConfig) cachedConf.Paths = new(BuildConfig)
cachedConf.API = new(APIConf)
if err := defaults.Set(&cachedConf); err != nil { if err := defaults.Set(&cachedConf); err != nil {
panic(err) panic(err)
} }

View File

@@ -46,6 +46,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi "00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi "00:0B": arm64-efi/snponly.efi
systemd name: tftp systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`, `,
}, },
"cidr": { "cidr": {
@@ -87,6 +92,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi "00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi "00:0B": arm64-efi/snponly.efi
systemd name: tftp systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`, `,
}, },
"cidr with conflicts": { "cidr with conflicts": {
@@ -130,6 +140,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi "00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi "00:0B": arm64-efi/snponly.efi
systemd name: tftp systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`, `,
}, },
"ipv6 cidr": { "ipv6 cidr": {
@@ -170,6 +185,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi "00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi "00:0B": arm64-efi/snponly.efi
systemd name: tftp systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`, `,
}, },
"ipv6 cidr conflict": { "ipv6 cidr conflict": {
@@ -211,6 +231,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi "00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi "00:0B": arm64-efi/snponly.efi
systemd name: tftp systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`, `,
}, },
"example": { "example": {
@@ -288,6 +313,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi "00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi "00:0B": arm64-efi/snponly.efi
systemd name: tftp systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`, `,
}, },
} }

View File

@@ -31,3 +31,7 @@ func ImageParentDir() string {
func ImageFile(name string) string { func ImageFile(name string) string {
return path.Join(ImageParentDir(), name+".img") return path.Join(ImageParentDir(), name+".img")
} }
func CompressedImageFile(name string) string {
return ImageFile(name) + ".gz"
}

View File

@@ -3,6 +3,7 @@ package image
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"time"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -114,3 +115,27 @@ func DeleteImage(name string) error {
func IsWriteAble(name string) bool { func IsWriteAble(name string) bool {
return !util.IsFile(filepath.Join(SourceDir(name), "readonly")) return !util.IsFile(filepath.Join(SourceDir(name), "readonly"))
} }
func ImageSize(name string) int {
if img, err := os.Stat(ImageFile(name)); err == nil {
return int(img.Size())
} else {
return 0
}
}
func ImageModTime(name string) time.Time {
if img, err := os.Stat(ImageFile(name)); err == nil {
return img.ModTime()
} else {
return time.Time{}
}
}
func CompressedImageSize(name string) int {
if img, err := os.Stat(CompressedImageFile(name)); err == nil {
return int(img.Size())
} else {
return 0
}
}

View File

@@ -166,28 +166,6 @@ func (config *NodesYaml) FindAllProfiles(nodes ...string) (profileList []Profile
return profileList, nil return profileList, nil
} }
/*
Return the names of all available nodes
*/
func (config *NodesYaml) ListAllNodes() []string {
nodeList := make([]string, len(config.Nodes))
for name := range config.Nodes {
nodeList = append(nodeList, name)
}
return nodeList
}
/*
Return the names of all available nodes
*/
func (config *NodesYaml) ListAllProfiles() []string {
var nodeList []string
for name := range config.NodeProfiles {
nodeList = append(nodeList, name)
}
return nodeList
}
/* /*
FindDiscoverableNode returns the first discoverable node and an FindDiscoverableNode returns the first discoverable node and an
interface to associate with the discovered interface. If the nodUNDEFe has interface to associate with the discovered interface. If the nodUNDEFe has

View File

@@ -24,8 +24,8 @@ type Node struct {
id string id string
valid bool // Is set true, if called by the constructor valid bool // Is set true, if called by the constructor
// exported values // exported values
Discoverable wwtype.WWbool `yaml:"discoverable,omitempty" lopt:"discoverable" sopt:"e" comment:"Make discoverable in given network (true/false)"` Discoverable wwtype.WWbool `yaml:"discoverable,omitempty" json:"discoverable,omitempty" lopt:"discoverable" sopt:"e" comment:"Make discoverable in given network (true/false)"`
AssetKey string `yaml:"asset key,omitempty" lopt:"asset" comment:"Set the node's Asset tag (key)"` AssetKey string `yaml:"asset key,omitempty" json:"asset key,omitempty" lopt:"asset" comment:"Set the node's Asset tag (key)"`
Profile `yaml:"-,inline"` // include all values set in the profile, but inline them in yaml output if these are part of Node Profile `yaml:"-,inline"` // include all values set in the profile, but inline them in yaml output if these are part of Node
} }
@@ -35,56 +35,56 @@ Holds the data which can be set for profiles and nodes.
type Profile struct { type Profile struct {
id string id string
// exported values // exported values
Profiles []string `yaml:"profiles,omitempty" lopt:"profile" sopt:"P" comment:"Set the node's profile members (comma separated)"` Profiles []string `yaml:"profiles,omitempty" json:"profiles,omitempty" lopt:"profile" sopt:"P" comment:"Set the node's profile members (comma separated)"`
Comment string `yaml:"comment,omitempty" lopt:"comment" comment:"Set arbitrary string comment"` Comment string `yaml:"comment,omitempty" json:"comment,omitempty" lopt:"comment" comment:"Set arbitrary string comment"`
ClusterName string `yaml:"cluster name,omitempty" lopt:"cluster" sopt:"c" comment:"Set cluster group"` ClusterName string `yaml:"cluster name,omitempty" json:"cluster name,omitempty" lopt:"cluster" sopt:"c" comment:"Set cluster group"`
ImageName string `yaml:"image name,omitempty" lopt:"image" comment:"Set image name"` ImageName string `yaml:"image name,omitempty" json:"image name,omitempty" lopt:"image" comment:"Set image name"`
Ipxe string `yaml:"ipxe template,omitempty" lopt:"ipxe" comment:"Set the iPXE template name"` Ipxe string `yaml:"ipxe template,omitempty" json:"ipxe template,omitempty" lopt:"ipxe" comment:"Set the iPXE template name"`
RuntimeOverlay []string `yaml:"runtime overlay,omitempty" lopt:"runtime-overlays" sopt:"R" comment:"Set the runtime overlay"` RuntimeOverlay []string `yaml:"runtime overlay,omitempty" json:"runtime overlay,omitempty" lopt:"runtime-overlays" sopt:"R" comment:"Set the runtime overlay"`
SystemOverlay []string `yaml:"system overlay,omitempty" lopt:"system-overlays" sopt:"O" comment:"Set the system overlay"` SystemOverlay []string `yaml:"system overlay,omitempty" json:"system overlay,omitempty" lopt:"system-overlays" sopt:"O" comment:"Set the system overlay"`
Kernel *KernelConf `yaml:"kernel,omitempty"` Kernel *KernelConf `yaml:"kernel,omitempty" json:"kernel,omitempty"`
Ipmi *IpmiConf `yaml:"ipmi,omitempty"` Ipmi *IpmiConf `yaml:"ipmi,omitempty" json:"ipmi,omitempty"`
Init string `yaml:"init,omitempty" lopt:"init" sopt:"i" comment:"Define the init process to boot the image"` Init string `yaml:"init,omitempty" json:"init,omitempty" lopt:"init" sopt:"i" comment:"Define the init process to boot the image"`
Root string `yaml:"root,omitempty" lopt:"root" comment:"Define the rootfs" ` Root string `yaml:"root,omitempty" json:"root,omitempty" lopt:"root" comment:"Define the rootfs" `
NetDevs map[string]*NetDev `yaml:"network devices,omitempty"` NetDevs map[string]*NetDev `yaml:"network devices,omitempty" json:"network devices,omitempty"`
Tags map[string]string `yaml:"tags,omitempty"` Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"`
PrimaryNetDev string `yaml:"primary network,omitempty" lopt:"primarynet" sopt:"p" comment:"Set the primary network interface"` PrimaryNetDev string `yaml:"primary network,omitempty" json:"primary network,omitempty" lopt:"primarynet" sopt:"p" comment:"Set the primary network interface"`
Disks map[string]*Disk `yaml:"disks,omitempty"` Disks map[string]*Disk `yaml:"disks,omitempty" json:"disks,omitempty"`
FileSystems map[string]*FileSystem `yaml:"filesystems,omitempty"` FileSystems map[string]*FileSystem `yaml:"filesystems,omitempty" json:"filesystems,omitempty"`
Resources map[string]Resource `yaml:"resources,omitempty"` Resources map[string]Resource `yaml:"resources,omitempty" json:"resources,omitempty"`
} }
type IpmiConf struct { type IpmiConf struct {
UserName string `yaml:"username,omitempty" lopt:"ipmiuser" comment:"Set the IPMI username"` UserName string `yaml:"username,omitempty" json:"username,omitempty" lopt:"ipmiuser" comment:"Set the IPMI username"`
Password string `yaml:"password,omitempty" lopt:"ipmipass" comment:"Set the IPMI password"` Password string `yaml:"password,omitempty" json:"password,omitempty" lopt:"ipmipass" comment:"Set the IPMI password"`
Ipaddr net.IP `yaml:"ipaddr,omitempty" lopt:"ipmiaddr" comment:"Set the IPMI IP address" type:"IP"` Ipaddr net.IP `yaml:"ipaddr,omitempty" json:"ipaddr,omitempty" lopt:"ipmiaddr" comment:"Set the IPMI IP address" type:"IP"`
Gateway net.IP `yaml:"gateway,omitempty" lopt:"ipmigateway" comment:"Set the IPMI gateway" type:"IP"` Gateway net.IP `yaml:"gateway,omitempty" json:"gateway,omitempty" lopt:"ipmigateway" comment:"Set the IPMI gateway" type:"IP"`
Netmask net.IP `yaml:"netmask,omitempty" lopt:"ipminetmask" comment:"Set the IPMI netmask" type:"IP"` Netmask net.IP `yaml:"netmask,omitempty" json:"netmask,omitempty" lopt:"ipminetmask" comment:"Set the IPMI netmask" type:"IP"`
Port string `yaml:"port,omitempty" lopt:"ipmiport" comment:"Set the IPMI port"` Port string `yaml:"port,omitempty" json:"port,omitempty" lopt:"ipmiport" comment:"Set the IPMI port"`
Interface string `yaml:"interface,omitempty" lopt:"ipmiinterface" comment:"Set the node's IPMI interface (defaults: 'lan')"` Interface string `yaml:"interface,omitempty" json:"interface,omitempty" lopt:"ipmiinterface" comment:"Set the node's IPMI interface (defaults: 'lan')"`
EscapeChar string `yaml:"escapechar,omitempty" lopt:"ipmiescapechar" comment:"Set the IPMI escape character (defaults: '~')"` EscapeChar string `yaml:"escapechar,omitempty" json:"escapechar,omitempty" lopt:"ipmiescapechar" comment:"Set the IPMI escape character (defaults: '~')"`
Write wwtype.WWbool `yaml:"write,omitempty" lopt:"ipmiwrite" comment:"Enable the write of impi configuration (true/false)"` Write wwtype.WWbool `yaml:"write,omitempty" json:"write,omitempty" lopt:"ipmiwrite" comment:"Enable the write of impi configuration (true/false)"`
Template string `yaml:"template,omitempty" lopt:"ipmitemplate" comment:"template used for ipmi command"` Template string `yaml:"template,omitempty" json:"template,omitempty" lopt:"ipmitemplate" comment:"template used for ipmi command"`
Tags map[string]string `yaml:"tags,omitempty"` Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"`
} }
type KernelConf struct { type KernelConf struct {
Version string `yaml:"version,omitempty" lopt:"kernelversion" comment:"Set kernel version" json:"version,omitempty"` Version string `yaml:"version,omitempty" json:"version,omitempty" lopt:"kernelversion" comment:"Set kernel version"`
Args []string `yaml:"args,omitempty" lopt:"kernelargs" sopt:"A" comment:"Set kernel arguments" json:"args,omitempty"` Args []string `yaml:"args,omitempty" json:"args,omitempty" lopt:"kernelargs" sopt:"A" comment:"Set kernel arguments"`
} }
type NetDev struct { type NetDev struct {
Type string `yaml:"type,omitempty" lopt:"type" sopt:"T" comment:"Set device type of given network"` Type string `yaml:"type,omitempty" json:"type,omitempty" lopt:"type" sopt:"T" comment:"Set device type of given network"`
OnBoot wwtype.WWbool `yaml:"onboot,omitempty" lopt:"onboot" comment:"Enable/disable network device (true/false)"` OnBoot wwtype.WWbool `yaml:"onboot,omitempty" json:"onbot,omitempty" lopt:"onboot" comment:"Enable/disable network device (true/false)"`
Device string `yaml:"device,omitempty" lopt:"netdev" sopt:"N" comment:"Set the device for given network"` Device string `yaml:"device,omitempty" json:"device,omitempty" lopt:"netdev" sopt:"N" comment:"Set the device for given network"`
Hwaddr string `yaml:"hwaddr,omitempty" lopt:"hwaddr" sopt:"H" comment:"Set the device's HW address for given network" type:"MAC"` Hwaddr string `yaml:"hwaddr,omitempty" json:"hwaddr,omitempty" lopt:"hwaddr" sopt:"H" comment:"Set the device's HW address for given network" type:"MAC"`
Ipaddr net.IP `yaml:"ipaddr,omitempty" comment:"IPv4 address in given network" sopt:"I" lopt:"ipaddr" type:"IP"` Ipaddr net.IP `yaml:"ipaddr,omitempty" json:"ipaddr,omitempty" lopt:"ipaddr" sopt:"I" comment:"IPv4 address in given network" type:"IP"`
Ipaddr6 net.IP `yaml:"ip6addr,omitempty" lopt:"ipaddr6" comment:"IPv6 address" type:"IP"` Ipaddr6 net.IP `yaml:"ip6addr,omitempty" json:"ip6addr,omitempty" lopt:"ipaddr6" comment:"IPv6 address" type:"IP"`
Prefix net.IP `yaml:"prefix,omitempty"` Prefix net.IP `yaml:"prefix,omitempty" json:"prefix,omitempty"`
Netmask net.IP `yaml:"netmask,omitempty" lopt:"netmask" sopt:"M" comment:"Set the networks netmask" type:"IP"` Netmask net.IP `yaml:"netmask,omitempty" json:"netmask,omitempty" lopt:"netmask" sopt:"M" comment:"Set the networks netmask" type:"IP"`
Gateway net.IP `yaml:"gateway,omitempty" lopt:"gateway" sopt:"G" comment:"Set the node's network device gateway" type:"IP"` Gateway net.IP `yaml:"gateway,omitempty" json:"gateway,omitempty" lopt:"gateway" sopt:"G" comment:"Set the node's network device gateway" type:"IP"`
MTU string `yaml:"mtu,omitempty" lopt:"mtu" comment:"Set the mtu" type:"uint"` MTU string `yaml:"mtu,omitempty" json:"mtu,omitempty" lopt:"mtu" comment:"Set the mtu" type:"uint"`
Tags map[string]string `yaml:"tags,omitempty"` Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"`
primary bool primary bool
} }
@@ -92,8 +92,8 @@ type NetDev struct {
Holds the disks of a node Holds the disks of a node
*/ */
type Disk struct { type Disk struct {
WipeTable bool `yaml:"wipe_table,omitempty" lopt:"diskwipe" comment:"whether or not the partition tables shall be wiped"` WipeTable bool `yaml:"wipe_table,omitempty" json:"wipe_table,omitempty" lopt:"diskwipe" comment:"whether or not the partition tables shall be wiped"`
Partitions map[string]*Partition `yaml:"partitions,omitempty"` Partitions map[string]*Partition `yaml:"partitions,omitempty" json:"partitions,omitempty"`
} }
/* /*
@@ -101,27 +101,27 @@ partition definition, the label must be uniq so its used as the key in the
Partitions map Partitions map
*/ */
type Partition struct { type Partition struct {
Number string `yaml:"number,omitempty" lopt:"partnumber" comment:"set the partition number, if not set next free slot is used" type:"uint"` Number string `yaml:"number,omitempty" json:"number,omitempty" lopt:"partnumber" comment:"set the partition number, if not set next free slot is used" type:"uint"`
SizeMiB string `yaml:"size_mib,omitempty" lopt:"partsize" comment:"set the size of the partition, if not set maximal possible size is used" type:"uint"` SizeMiB string `yaml:"size_mib,omitempty" json:"size_mib,omitempty" lopt:"partsize" comment:"set the size of the partition, if not set maximal possible size is used" type:"uint"`
StartMiB string `yaml:"start_mib,omitempty" comment:"the start of the partition" type:"uint"` StartMiB string `yaml:"start_mib,omitempty" json:"start_mib,omitempty" comment:"the start of the partition" type:"uint"`
TypeGuid string `yaml:"type_guid,omitempty" comment:"Linux filesystem data will be used if empty"` TypeGuid string `yaml:"type_guid,omitempty" json:"type_guid,omitempty" comment:"Linux filesystem data will be used if empty"`
Guid string `yaml:"guid,omitempty" comment:"the GPT unique partition GUID"` Guid string `yaml:"guid,omitempty" json:"guid,omitempty" comment:"the GPT unique partition GUID"`
WipePartitionEntry bool `yaml:"wipe_partition_entry,omitempty" comment:"if true, Ignition will clobber an existing partition if it does not match the config"` WipePartitionEntry bool `yaml:"wipe_partition_entry,omitempty" json:"wipe_partition_entry,omitempty" comment:"if true, Ignition will clobber an existing partition if it does not match the config"`
ShouldExist bool `yaml:"should_exist,omitempty" lopt:"partcreate" comment:"create partition if not exist"` ShouldExist bool `yaml:"should_exist,omitempty" json:"should_exist,omitempty" lopt:"partcreate" comment:"create partition if not exist"`
Resize bool `yaml:"resize,omitempty" comment:" whether or not the existing partition should be resize"` Resize bool `yaml:"resize,omitempty" json:"resize,omitempty" comment:"whether or not the existing partition should be resize"`
} }
/* /*
Definition of a filesystem. The device is uniq so its used as key Definition of a filesystem. The device is uniq so its used as key
*/ */
type FileSystem struct { type FileSystem struct {
Format string `yaml:"format,omitempty" lopt:"fsformat" comment:"format of the file system"` Format string `yaml:"format,omitempty" json:"format,omitempty" lopt:"fsformat" comment:"format of the file system"`
Path string `yaml:"path,omitempty" lopt:"fspath" comment:"the mount point of the file system"` Path string `yaml:"path,omitempty" json:"path,omitempty" lopt:"fspath" comment:"the mount point of the file system"`
WipeFileSystem bool `yaml:"wipe_filesystem,omitempty" lopt:"fswipe" comment:"wipe file system at boot"` WipeFileSystem bool `yaml:"wipe_filesystem,omitempty" json:"wipe_filesystem,omitempty" lopt:"fswipe" comment:"wipe file system at boot"`
Label string `yaml:"label,omitempty" comment:"the label of the filesystem"` Label string `yaml:"label,omitempty" json:"label,omitempty" comment:"the label of the filesystem"`
Uuid string `yaml:"uuid,omitempty" comment:"the uuid of the filesystem"` Uuid string `yaml:"uuid,omitempty" json:"uuid,omitempty" comment:"the uuid of the filesystem"`
Options []string `yaml:"options,omitempty" comment:"any additional options to be passed to the format-specific mkfs utility"` Options []string `yaml:"options,omitempty" json:"options,omitempty" comment:"any additional options to be passed to the format-specific mkfs utility"`
MountOptions string `yaml:"mount_options,omitempty" comment:"any special options to be passed to the mount command"` MountOptions string `yaml:"mount_options,omitempty" json:"mount_options,omitempty" comment:"any special options to be passed to the mount command"`
} }
type Resource interface{} type Resource interface{}

View File

@@ -356,7 +356,7 @@ func (node *Node) updatePrimaryNetDev() {
} }
sort.Strings(keys) sort.Strings(keys)
if len(keys) > 0 { if len(keys) > 0 {
wwlog.Debug("%s: no primary defined, sanitizing to: %s", node.id, keys[0]) wwlog.Debug("%s: no primary netdev defined, sanitizing to: %s", node.id, keys[0])
node.NetDevs[keys[0]].primary = true node.NetDevs[keys[0]].primary = true
node.PrimaryNetDev = keys[0] node.PrimaryNetDev = keys[0]
} }

View File

@@ -0,0 +1,104 @@
package node
import "slices"
// ListAllNodes returns a slice of all node names defined in the Nodes map.
func (config *NodesYaml) ListAllNodes() []string {
nodes := []string{}
for n := range config.Nodes {
nodes = append(nodes, n)
}
slices.Sort(nodes)
return nodes
}
// ListAllProfiles returns a slice of all profile IDs defined in the
// NodeProfiles map.
func (config *NodesYaml) ListAllProfiles() []string {
profiles := []string{}
for p := range config.NodeProfiles {
profiles = append(profiles, p)
}
slices.Sort(profiles)
return profiles
}
// ListNodesUsingProfile returns a slice of node IDs that reference the
// specified profileID.
func (config *NodesYaml) ListNodesUsingProfile(profileID string) []string {
nodes := []string{}
for n := range config.Nodes {
if slices.Contains(config.Nodes[n].Profiles, profileID) {
nodes = append(nodes, n)
}
}
slices.Sort(nodes)
return nodes
}
// ListProfilesUsingProfile returns a slice of profile IDs from NodeProfiles
// that reference the specified profileID.
func (config *NodesYaml) ListProfilesUsingProfile(profileID string) []string {
profiles := []string{}
for p := range config.NodeProfiles {
if slices.Contains(config.NodeProfiles[p].Profiles, profileID) {
profiles = append(profiles, p)
}
}
slices.Sort(profiles)
return profiles
}
// ListNodesUsingImage returns a slice of node IDs for nodes that use the
// specified image.
func (config *NodesYaml) ListNodesUsingImage(image string) []string {
nodes := []string{}
for n := range config.Nodes {
if config.Nodes[n].ImageName == image {
nodes = append(nodes, n)
}
}
slices.Sort(nodes)
return nodes
}
// ListProfilesUsingImage returns a slice of profile IDs for profiles that use
// the specified image.
func (config *NodesYaml) ListProfilesUsingImage(image string) []string {
profiles := []string{}
for p := range config.NodeProfiles {
if config.NodeProfiles[p].ImageName == image {
profiles = append(profiles, p)
}
}
slices.Sort(profiles)
return profiles
}
// ListNodesUsingOverlay returns a slice of node IDs for nodes that include the
// specified overlay in either RuntimeOverlay or SystemOverlay.
func (config *NodesYaml) ListNodesUsingOverlay(overlay string) []string {
nodes := []string{}
for n := range config.Nodes {
if slices.Contains(config.Nodes[n].RuntimeOverlay, overlay) ||
slices.Contains(config.Nodes[n].SystemOverlay, overlay) {
nodes = append(nodes, n)
}
}
slices.Sort(nodes)
return nodes
}
// ListProfilesUsingOverlay returns a slice of profile IDs for profiles that
// include the specified overlay in either RuntimeOverlay or SystemOverlay.
func (config *NodesYaml) ListProfilesUsingOverlay(overlay string) []string {
profiles := []string{}
for p := range config.NodeProfiles {
if slices.Contains(config.NodeProfiles[p].RuntimeOverlay, overlay) ||
slices.Contains(config.NodeProfiles[p].SystemOverlay, overlay) {
profiles = append(profiles, p)
}
}
slices.Sort(profiles)
return profiles
}

View File

@@ -0,0 +1,395 @@
package node
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_ListAllNodes(t *testing.T) {
tests := map[string]struct {
registry string
nodes []string
}{
"empty": {
registry: ``,
nodes: []string{},
},
"one node": {
registry: `
nodes:
n1: {}`,
nodes: []string{"n1"},
},
"multiple nodes": {
registry: `
nodes:
n1: {}
n2: {}`,
nodes: []string{"n1", "n2"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
registry, err := Parse([]byte(tt.registry))
assert.NoError(t, err)
assert.Equal(t, tt.nodes, registry.ListAllNodes())
})
}
}
func Test_ListAllProfiles(t *testing.T) {
tests := map[string]struct {
registry string
profiles []string
}{
"empty": {
registry: ``,
profiles: []string{},
},
"one profile": {
registry: `
nodeprofiles:
p1: {}`,
profiles: []string{"p1"},
},
"multiple profiles": {
registry: `
nodeprofiles:
p1: {}
p2: {}`,
profiles: []string{"p1", "p2"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
registry, err := Parse([]byte(tt.registry))
assert.NoError(t, err)
assert.Equal(t, tt.profiles, registry.ListAllProfiles())
})
}
}
func Test_ListNodesUsingProfile(t *testing.T) {
tests := map[string]struct {
registry string
profile string
nodes []string
}{
"empty": {
registry: ``,
profile: "p1",
nodes: []string{},
},
"node without profle": {
registry: `
nodes:
n1: {}`,
profile: "p1",
nodes: []string{},
},
"node with profle": {
registry: `
nodes:
n1:
profiles:
- p1`,
profile: "p1",
nodes: []string{"n1"},
},
"multiple nodes one with profile": {
registry: `
nodes:
n1: {}
n2:
profiles:
- p1`,
profile: "p1",
nodes: []string{"n2"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
registry, err := Parse([]byte(tt.registry))
assert.NoError(t, err)
assert.Equal(t, tt.nodes, registry.ListNodesUsingProfile(tt.profile))
})
}
}
func Test_ListProfilesUsingProfile(t *testing.T) {
tests := map[string]struct {
registry string
profile string
profiles []string
}{
"empty": {
registry: ``,
profile: "p1",
profiles: []string{},
},
"profile without profle": {
registry: `
nodeprofiles:
p1: {}`,
profile: "p2",
profiles: []string{},
},
"profile with profile": {
registry: `
nodeprofiles:
p1:
profiles:
- p2`,
profile: "p2",
profiles: []string{"p1"},
},
"multiple profiles one with profile": {
registry: `
nodeprofiles:
p1: {}
p2:
profiles:
- p3`,
profile: "p3",
profiles: []string{"p2"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
registry, err := Parse([]byte(tt.registry))
assert.NoError(t, err)
assert.Equal(t, tt.profiles, registry.ListProfilesUsingProfile(tt.profile))
})
}
}
func Test_ListNodesUsingImage(t *testing.T) {
tests := map[string]struct {
registry string
image string
nodes []string
}{
"empty": {
registry: ``,
image: "i1",
nodes: []string{},
},
"node without image": {
registry: `
nodes:
n1: {}`,
image: "i1",
nodes: []string{},
},
"node with image": {
registry: `
nodes:
n1:
image name: i1`,
image: "i1",
nodes: []string{"n1"},
},
"multiple nodes one with profile": {
registry: `
nodes:
n1: {}
n2:
image name: i1`,
image: "i1",
nodes: []string{"n2"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
registry, err := Parse([]byte(tt.registry))
assert.NoError(t, err)
assert.Equal(t, tt.nodes, registry.ListNodesUsingImage(tt.image))
})
}
}
func Test_ListProfilesUsingImage(t *testing.T) {
tests := map[string]struct {
registry string
image string
profiles []string
}{
"empty": {
registry: ``,
image: "i1",
profiles: []string{},
},
"profile without image": {
registry: `
nodeprofiles:
p1: {}`,
image: "i1",
profiles: []string{},
},
"profile with image": {
registry: `
nodeprofiles:
p1:
image name: i1`,
image: "i1",
profiles: []string{"p1"},
},
"multiple profiles one with image": {
registry: `
nodeprofiles:
p1: {}
p2:
image name: i1`,
image: "i1",
profiles: []string{"p2"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
registry, err := Parse([]byte(tt.registry))
assert.NoError(t, err)
assert.Equal(t, tt.profiles, registry.ListProfilesUsingImage(tt.image))
})
}
}
func Test_ListNodesUsingOverlay(t *testing.T) {
tests := map[string]struct {
registry string
overlay string
nodes []string
}{
"empty": {
registry: ``,
overlay: "o1",
nodes: []string{},
},
"node without profle": {
registry: `
nodes:
n1: {}`,
overlay: "o1",
nodes: []string{},
},
"node with runtime overlay": {
registry: `
nodes:
n1:
runtime overlay:
- o1`,
overlay: "o1",
nodes: []string{"n1"},
},
"multiple nodes one with runtime overlay": {
registry: `
nodes:
n1: {}
n2:
runtime overlay:
- o1`,
overlay: "o1",
nodes: []string{"n2"},
},
"node with system overlay": {
registry: `
nodes:
n1:
system overlay:
- o1`,
overlay: "o1",
nodes: []string{"n1"},
},
"multiple nodes one with system overlay": {
registry: `
nodes:
n1: {}
n2:
system overlay:
- o1`,
overlay: "o1",
nodes: []string{"n2"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
registry, err := Parse([]byte(tt.registry))
assert.NoError(t, err)
assert.Equal(t, tt.nodes, registry.ListNodesUsingOverlay(tt.overlay))
})
}
}
func Test_ListProfilesUsingOverlay(t *testing.T) {
tests := map[string]struct {
registry string
overlay string
profiles []string
}{
"empty": {
registry: ``,
overlay: "o1",
profiles: []string{},
},
"node without profle": {
registry: `
nodeprofiles:
p1: {}`,
overlay: "o1",
profiles: []string{},
},
"node with runtime overlay": {
registry: `
nodeprofiles:
p1:
runtime overlay:
- o1`,
overlay: "o1",
profiles: []string{"p1"},
},
"multiple nodes one with runtime overlay": {
registry: `
nodeprofiles:
p1: {}
p2:
runtime overlay:
- o1`,
overlay: "o1",
profiles: []string{"p2"},
},
"node with system overlay": {
registry: `
nodeprofiles:
p1:
system overlay:
- o1`,
overlay: "o1",
profiles: []string{"p1"},
},
"multiple nodes one with system overlay": {
registry: `
nodeprofiles:
p1: {}
p2:
system overlay:
- o1`,
overlay: "o1",
profiles: []string{"p2"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
registry, err := Parse([]byte(tt.registry))
assert.NoError(t, err)
assert.Equal(t, tt.profiles, registry.ListProfilesUsingOverlay(tt.overlay))
})
}
}

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 { if err == nil {
wwlog.SetLogLevel(level) wwlog.SetLogLevel(level)
} }
} else {
wwlog.SetLogLevel(wwlog.INFO)
} }
loginit = true loginit = true

View File

@@ -106,3 +106,13 @@ func GetNodeOrSetDiscoverable(hwaddr string) (node.Node, error) {
// return the discovered node // return the discovered node
return db.yml.GetNode(nodeFound.Id()) 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
}

View File

@@ -227,8 +227,8 @@ be rebuilt into a bootable static object automatically. (To skip the automatic
image rebuild, specify ``--build=false``.) image rebuild, specify ``--build=false``.)
If the files ``/etc/passwd`` or ``/etc/group`` were updated, there will be an If the files ``/etc/passwd`` or ``/etc/group`` were updated, there will be an
additional check to confirm if the users are in sync as described in `Syncuser`_ additional check to confirm if the users are in sync as described in the
section. :ref:`Syncuser` section.
Specifying a Prompt Specifying a Prompt
------------------- -------------------

View File

@@ -27,6 +27,7 @@ Welcome to the Warewulf User Guide!
Security <server/security> Security <server/security>
Bootloaders <server/bootloaders> Bootloaders <server/bootloaders>
Upgrading Warewulf <server/upgrade> Upgrading Warewulf <server/upgrade>
REST API <server/api>
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1

View File

@@ -284,6 +284,8 @@ Two SSH overlays configure host keys (one set for all node in the cluster) and
- ssh.authorized_keys - ssh.authorized_keys
- ssh.host_keys - ssh.host_keys
.. _Syncuser:
syncuser syncuser
-------- --------

75
userdocs/server/api.rst Normal file
View File

@@ -0,0 +1,75 @@
========
REST API
========
On-line documentation for the API is available at ``/api/docs``.
Authentication
==============
Authentication is managed at ``/etc/warewulf/auth.conf``. This is a
YAML formatted file with a single key: ``users:``, that is a list of user names
and passwords able to authenticate to the API.
.. warning::
Because ``warewulfd`` runs as ``root`` by default, and because ``warewulfd``
can run effectively arbitrary code via overlay templates, API access is
tantamount to ``root`` access on the Warewulf server. For this reason, the
API is only accessible via localhost by default. Still, handle API
credentials with care.
.. code-block:: yaml
users:
- name: admin
password hash: $2b$05$5QVWDpiWE7L4SDL9CYdi3O/l6HnbNOLoXgY2sa1bQQ7aSBKdSqvsC
Passwords are stored as bcrypt2 hashses, which can be generated with ``mkpasswd``.
.. code-block:: console
$ mkpasswd --method=bcrypt
Password: # admin
$2b$05$5QVWDpiWE7L4SDL9CYdi3O/l6HnbNOLoXgY2sa1bQQ7aSBKdSqvsC
Node
====
* ``GET /api/nodes/``: Get nodes
* ``POST /api/nodes/overlays/build``: Build all overlays
* ``DELETE /api/nodes/{id}``: Delete an existing node
* ``GET /api/nodes/{id}``: Get a node
* ``PATCH /api/nodes/{id}``: Update an existing node
* ``PUT /api/nodes/{id}``: Add a node
* ``GET /api/nodes/{id}/fields``: Get node fields
* ``POST /api/nodes/{id}/overlays/build``: Build overlays for a node
* ``GET /api/nodes/{id}/raw``: Get a raw node
Profile
=======
* ``GET /api/profiles/``: Get node profiles
* ``DELETE /api/profiles/{id}``: Delete an existing profile
* ``GET /api/profiles/{id}``: Get a node profile
* ``PATCH /api/profiles/{id}``: Update an existing profile
* ``PUT /api/profiles/{id}``: Add a profile
Image
=====
* ``GET /api/images``: Get all images
* ``DELETE /api/images/{name}``: Delete an image
* ``GET /api/images/{name}``: Get an image
* ``PATCH /api/images/{name}``: Update or rename an image
* ``POST /api/images/{name}/build``: Build an image
* ``POST /api/images/{name}/import``: Import an image
Overlay
=======
* ``GET /api/overlays/``: Get overlays
* ``DELETE /api/overlays/{name}``: Delete an overlay
* ``GET /api/overlays/{name}``: Get an overlay
* ``PUT /api/overlays/{name}``: Create an overlay
* ``GET /api/overlays/{name}/file``: Get an overlay file

View File

@@ -333,6 +333,26 @@ Configuration for the ``wwclient`` service on cluster nodes.
``wwclient`` will use the TCP port "987" by default if ``secure: true``; but, ``wwclient`` will use the TCP port "987" by default if ``secure: true``; but,
if that port is otherwise in use, a different port may be specified. if that port is otherwise in use, a different port may be specified.
api
===
*New in Warewulf v4.6.1*
Configuration for the REST API of the ``warewulfd`` service.
.. code-block:: yaml
api:
enabled: true
allowed subnets:
- 127.0.0.0/8
- ::1/128
* ``api:enabled``: Whether the ``warewulfd`` service should provide access via a
REST interface.
* ``api:allowed subnets``: Which subnets are allowed to access the REST API. By
default, only localhost has access.
hostfile hostfile
======== ========

View File

@@ -1,7 +1,5 @@
%global debug_package %{nil} %global debug_package %{nil}
%global api 0
%if 0%{?suse_version} %if 0%{?suse_version}
%global tftpdir /srv/tftpboot %global tftpdir /srv/tftpboot
%else %else
@@ -73,9 +71,6 @@ BuildRequires: git
BuildRequires: make BuildRequires: make
BuildRequires: gpgme-devel BuildRequires: gpgme-devel
BuildRequires: python3-devel BuildRequires: python3-devel
%if %{api}
BuildRequires: libassuan-devel
%endif
Recommends: logrotate Recommends: logrotate
Recommends: ipmitool Recommends: ipmitool
@@ -113,9 +108,6 @@ make defaults \
SOSPLUGINS=%{python3_sitelib}/sos/report/plugins \ SOSPLUGINS=%{python3_sitelib}/sos/report/plugins \
CACHEDIR=%{_localstatedir}/cache CACHEDIR=%{_localstatedir}/cache
make build make build
%if %{api}
make api
%endif
%install %install
@@ -123,10 +115,6 @@ export OFFLINE_BUILD=1
export NO_BRP_STALE_LINK_ERROR=yes export NO_BRP_STALE_LINK_ERROR=yes
make install \ make install \
DESTDIR=%{buildroot} DESTDIR=%{buildroot}
%if %{api}
make installapi \
DESTDIR=%{buildroot}
%endif
%if 0%{?suse_version} || 0%{?sle_version} %if 0%{?suse_version} || 0%{?sle_version}
yq e ' yq e '
@@ -162,6 +150,7 @@ getent group %{wwgroup} >/dev/null || groupadd -r %{wwgroup}
%dir %{_sysconfdir}/warewulf %dir %{_sysconfdir}/warewulf
%config(noreplace) %{_sysconfdir}/warewulf/warewulf.conf %config(noreplace) %{_sysconfdir}/warewulf/warewulf.conf
%config(noreplace) %attr(0640,-,%{wwgroup}) %{_sysconfdir}/warewulf/nodes.conf %config(noreplace) %attr(0640,-,%{wwgroup}) %{_sysconfdir}/warewulf/nodes.conf
%config(noreplace) %attr(0600,-,-) %{_sysconfdir}/warewulf/auth.conf
%config(noreplace) %{_sysconfdir}/warewulf/examples %config(noreplace) %{_sysconfdir}/warewulf/examples
%config(noreplace) %{_sysconfdir}/warewulf/ipxe %config(noreplace) %{_sysconfdir}/warewulf/ipxe
%config(noreplace) %{_sysconfdir}/warewulf/grub %config(noreplace) %{_sysconfdir}/warewulf/grub
@@ -208,11 +197,6 @@ getent group %{wwgroup} >/dev/null || groupadd -r %{wwgroup}
%dir %{_docdir}/warewulf %dir %{_docdir}/warewulf
%license %{_docdir}/warewulf/LICENSE.md %license %{_docdir}/warewulf/LICENSE.md
%if %{api}
%{_bindir}/wwapi*
%config(noreplace) %{_sysconfdir}/warewulf/wwapi*.conf
%endif
%package dracut %package dracut
Summary: dracut module for loading a Warewulf image Summary: dracut module for loading a Warewulf image
@@ -260,6 +244,9 @@ about Warewulf in an sos report.
%changelog %changelog
* Mon Apr 1 2025 Jonathon Anderson <janderson@ciq.com>
- Remove gRPC API
* Thu Feb 20 2025 Stephen Simpson <ssimpson@ciq.com> * Thu Feb 20 2025 Stephen Simpson <ssimpson@ciq.com>
- Added sos plugin - Added sos plugin