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.
- Set an IPMI tag ``vlan`` to configure the vlan during ``ipmiwrite``. #1031
- Added net.ifnames=1 to default kernel argument list. #1820
- Add a new OpenAPI v3 REST API to warewulfd at /api. #1588
### 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
- `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
- Allow `--verbose` and `--debug` to set log level for warewulfd. #1801
### Changed
- Update GitHub actions to build aarch64 artifacts.
- Explicitly enforce the number or arguments accepted by some `wwctl` subcommands. #1717
- 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

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>
## 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
**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>
## 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
**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>
## 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
**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>
## 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
**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>
## 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
**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>
## github.com/vearutop/statigz
**License:** MIT
**License URL:** <https://github.com/vearutop/statigz/blob/v1.4.0/LICENSE>
## github.com/vincent-petithory/dataurl
**License:** MIT

View File

@@ -20,9 +20,6 @@ include Tools.mk
.PHONY: build
build: wwctl wwclient etc/bash_completion.d/wwctl ## Build the Warewulf binaries
.PHONY: api
api: wwapid wwapic wwapird
.PHONY: docs
docs: man_pages reference ## Build the documentation
@@ -48,12 +45,6 @@ config: $(config)
$(config): Defaults.mk
apiconfig = etc/wwapic.conf \
etc/wwapid.conf \
etc/wwapird.conf
.PHONY: apiconfig
apiconfig: $(apiconfig)
%: %.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)
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
man_pages: wwctl $(wildcard docs/man/man5/*.5)
mkdir -p docs/man/man1
@@ -162,7 +144,6 @@ cleanmake:
.PHONY: cleanbin
cleanbin:
rm -f wwapi{c,d,rd}
rm -f wwclient
rm -f wwctl
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
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)/auth.conf || install -m 0600 etc/auth.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/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 -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
init:
systemctl daemon-reload
@@ -255,9 +228,6 @@ ifndef OFFLINE_BUILD
wwctl: vendor
wwclient: vendor
update_configuration: vendor
wwapid: vendor
wwapic: vendor
wwapird: vendor
dist: vendor
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/creasty/defaults v1.8.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/google/uuid v1.6.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1
@@ -27,7 +28,12 @@ require (
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.9.1
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
golang.org/x/crypto v0.32.0
golang.org/x/sys v0.29.0
golang.org/x/term v0.28.0
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/rootless-containers/proto v0.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/shopspring/decimal v1.4.0 // 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/pflag v1.0.6 // 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/ulikunitz/xz v0.5.12 // indirect
github.com/urfave/cli v1.22.16 // indirect
github.com/vbatts/go-mtree v0.5.0 // indirect
github.com/vbatts/tar-split v0.11.7 // 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
go.mongodb.org/mongo-driver v1.14.0 // 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/metric 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/net v0.33.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // 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/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/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/go.mod h1:UMNC4vQNC7hb5gyr47r18ylK1n34rV7GO+gb0wpXvcE=
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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
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/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc=
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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
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/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
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/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
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/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU=
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/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
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/go.mod h1:vk76naUSZaWE8Z95wbDn51FgH0goECM4oK3KY2hYSMU=
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/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/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/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
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/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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/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.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
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-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -459,27 +459,9 @@ nodes:
stdout: `
{
"n01": {
"Discoverable": "",
"AssetKey": "",
"Profiles": [
"profiles": [
"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: `
{
"n01": {
"Discoverable": "",
"AssetKey": "",
"Profiles": [
"profiles": [
"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": {
"Discoverable": "",
"AssetKey": "",
"Profiles": [
"profiles": [
"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"},
output: `
{
"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
}
"default": {}
}
`,
inDb: `
@@ -205,44 +187,8 @@ nodes:
args: []string{"-j"},
output: `
{
"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
},
"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
}
"default": {},
"test": {}
}
`,
inDb: `

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/warewulf/warewulf/internal/app/wwctl/completions"
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
"github.com/warewulf/warewulf/internal/pkg/warewulfd/server"
)
var (
@@ -31,7 +32,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
if err := warewulfd.DaemonInitLogging(); err != nil {
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 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://") ||
strings.HasPrefix(cip.Source, "file://") || util.IsFile(cip.Source) {
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 {
return
}
@@ -386,7 +386,7 @@ func ImageRename(crp *wwapiv1.ImageRenameParameter) (err error) {
}
// 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{}
// only check env if noHttps wasn't set
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")
}
func (paths BuildConfig) AuthenticationConf() string {
return path.Join(paths.Sysconfdir, "warewulf", "auth.conf")
}
func (paths BuildConfig) OciBlobCachedir() string {
return path.Join(paths.Cachedir, "warewulf")
}

View File

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

View File

@@ -46,6 +46,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi
systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`,
},
"cidr": {
@@ -87,6 +92,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi
systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`,
},
"cidr with conflicts": {
@@ -130,6 +140,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi
systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`,
},
"ipv6 cidr": {
@@ -170,6 +185,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi
systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`,
},
"ipv6 cidr conflict": {
@@ -211,6 +231,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi
systemd name: tftp
api:
enabled: false
allowed subnets:
- 127.0.0.0/8
- ::1/128
`,
},
"example": {
@@ -288,6 +313,11 @@ tftp:
"00:09": ipxe-snponly-x86_64.efi
"00:0B": arm64-efi/snponly.efi
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 {
return path.Join(ImageParentDir(), name+".img")
}
func CompressedImageFile(name string) string {
return ImageFile(name) + ".gz"
}

View File

@@ -3,6 +3,7 @@ package image
import (
"os"
"path/filepath"
"time"
"github.com/pkg/errors"
@@ -114,3 +115,27 @@ func DeleteImage(name string) error {
func IsWriteAble(name string) bool {
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 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
interface to associate with the discovered interface. If the nodUNDEFe has

View File

@@ -24,8 +24,8 @@ type Node struct {
id string
valid bool // Is set true, if called by the constructor
// exported values
Discoverable wwtype.WWbool `yaml:"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)"`
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" 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
}
@@ -35,56 +35,56 @@ Holds the data which can be set for profiles and nodes.
type Profile struct {
id string
// exported values
Profiles []string `yaml:"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"`
ClusterName string `yaml:"cluster name,omitempty" lopt:"cluster" sopt:"c" comment:"Set cluster group"`
ImageName string `yaml:"image name,omitempty" lopt:"image" comment:"Set image name"`
Ipxe string `yaml:"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"`
SystemOverlay []string `yaml:"system overlay,omitempty" lopt:"system-overlays" sopt:"O" comment:"Set the system overlay"`
Kernel *KernelConf `yaml:"kernel,omitempty"`
Ipmi *IpmiConf `yaml:"ipmi,omitempty"`
Init string `yaml:"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" `
NetDevs map[string]*NetDev `yaml:"network devices,omitempty"`
Tags map[string]string `yaml:"tags,omitempty"`
PrimaryNetDev string `yaml:"primary network,omitempty" lopt:"primarynet" sopt:"p" comment:"Set the primary network interface"`
Disks map[string]*Disk `yaml:"disks,omitempty"`
FileSystems map[string]*FileSystem `yaml:"filesystems,omitempty"`
Resources map[string]Resource `yaml:"resources,omitempty"`
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" json:"comment,omitempty" lopt:"comment" comment:"Set arbitrary string comment"`
ClusterName string `yaml:"cluster name,omitempty" json:"cluster name,omitempty" lopt:"cluster" sopt:"c" comment:"Set cluster group"`
ImageName string `yaml:"image name,omitempty" json:"image name,omitempty" lopt:"image" comment:"Set image 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" json:"runtime overlay,omitempty" lopt:"runtime-overlays" sopt:"R" comment:"Set the runtime 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" json:"kernel,omitempty"`
Ipmi *IpmiConf `yaml:"ipmi,omitempty" json:"ipmi,omitempty"`
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" json:"root,omitempty" lopt:"root" comment:"Define the rootfs" `
NetDevs map[string]*NetDev `yaml:"network devices,omitempty" json:"network devices,omitempty"`
Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"`
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" json:"disks,omitempty"`
FileSystems map[string]*FileSystem `yaml:"filesystems,omitempty" json:"filesystems,omitempty"`
Resources map[string]Resource `yaml:"resources,omitempty" json:"resources,omitempty"`
}
type IpmiConf struct {
UserName string `yaml:"username,omitempty" lopt:"ipmiuser" comment:"Set the IPMI username"`
Password string `yaml:"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"`
Gateway net.IP `yaml:"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"`
Port string `yaml:"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')"`
EscapeChar string `yaml:"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)"`
Template string `yaml:"template,omitempty" lopt:"ipmitemplate" comment:"template used for ipmi command"`
Tags map[string]string `yaml:"tags,omitempty"`
UserName string `yaml:"username,omitempty" json:"username,omitempty" lopt:"ipmiuser" comment:"Set the IPMI username"`
Password string `yaml:"password,omitempty" json:"password,omitempty" lopt:"ipmipass" comment:"Set the IPMI password"`
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" json:"gateway,omitempty" lopt:"ipmigateway" comment:"Set the IPMI gateway" 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" json:"port,omitempty" lopt:"ipmiport" comment:"Set the IPMI port"`
Interface string `yaml:"interface,omitempty" json:"interface,omitempty" lopt:"ipmiinterface" comment:"Set the node's IPMI interface (defaults: 'lan')"`
EscapeChar string `yaml:"escapechar,omitempty" json:"escapechar,omitempty" lopt:"ipmiescapechar" comment:"Set the IPMI escape character (defaults: '~')"`
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" json:"template,omitempty" lopt:"ipmitemplate" comment:"template used for ipmi command"`
Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"`
}
type KernelConf struct {
Version string `yaml:"version,omitempty" lopt:"kernelversion" comment:"Set kernel version" json:"version,omitempty"`
Args []string `yaml:"args,omitempty" lopt:"kernelargs" sopt:"A" comment:"Set kernel arguments" json:"args,omitempty"`
Version string `yaml:"version,omitempty" json:"version,omitempty" lopt:"kernelversion" comment:"Set kernel version"`
Args []string `yaml:"args,omitempty" json:"args,omitempty" lopt:"kernelargs" sopt:"A" comment:"Set kernel arguments"`
}
type NetDev struct {
Type string `yaml:"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)"`
Device string `yaml:"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"`
Ipaddr net.IP `yaml:"ipaddr,omitempty" comment:"IPv4 address in given network" sopt:"I" lopt:"ipaddr" type:"IP"`
Ipaddr6 net.IP `yaml:"ip6addr,omitempty" lopt:"ipaddr6" comment:"IPv6 address" type:"IP"`
Prefix net.IP `yaml:"prefix,omitempty"`
Netmask net.IP `yaml:"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"`
MTU string `yaml:"mtu,omitempty" lopt:"mtu" comment:"Set the mtu" type:"uint"`
Tags map[string]string `yaml:"tags,omitempty"`
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" json:"onbot,omitempty" lopt:"onboot" comment:"Enable/disable network device (true/false)"`
Device string `yaml:"device,omitempty" json:"device,omitempty" lopt:"netdev" sopt:"N" comment:"Set the device for given network"`
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" json:"ipaddr,omitempty" lopt:"ipaddr" sopt:"I" comment:"IPv4 address in given network" type:"IP"`
Ipaddr6 net.IP `yaml:"ip6addr,omitempty" json:"ip6addr,omitempty" lopt:"ipaddr6" comment:"IPv6 address" type:"IP"`
Prefix net.IP `yaml:"prefix,omitempty" json:"prefix,omitempty"`
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" json:"gateway,omitempty" lopt:"gateway" sopt:"G" comment:"Set the node's network device gateway" type:"IP"`
MTU string `yaml:"mtu,omitempty" json:"mtu,omitempty" lopt:"mtu" comment:"Set the mtu" type:"uint"`
Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"`
primary bool
}
@@ -92,8 +92,8 @@ type NetDev struct {
Holds the disks of a node
*/
type Disk struct {
WipeTable bool `yaml:"wipe_table,omitempty" lopt:"diskwipe" comment:"whether or not the partition tables shall be wiped"`
Partitions map[string]*Partition `yaml:"partitions,omitempty"`
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" json:"partitions,omitempty"`
}
/*
@@ -101,27 +101,27 @@ partition definition, the label must be uniq so its used as the key in the
Partitions map
*/
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"`
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"`
StartMiB string `yaml:"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"`
Guid string `yaml:"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"`
ShouldExist bool `yaml:"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"`
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" 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" json:"start_mib,omitempty" comment:"the start of the partition" type:"uint"`
TypeGuid string `yaml:"type_guid,omitempty" json:"type_guid,omitempty" comment:"Linux filesystem data will be used if empty"`
Guid string `yaml:"guid,omitempty" json:"guid,omitempty" comment:"the GPT unique partition GUID"`
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" json:"should_exist,omitempty" lopt:"partcreate" comment:"create partition if not exist"`
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
*/
type FileSystem struct {
Format string `yaml:"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"`
WipeFileSystem bool `yaml:"wipe_filesystem,omitempty" lopt:"fswipe" comment:"wipe file system at boot"`
Label string `yaml:"label,omitempty" comment:"the label of the filesystem"`
Uuid string `yaml:"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"`
MountOptions string `yaml:"mount_options,omitempty" comment:"any special options to be passed to the mount command"`
Format string `yaml:"format,omitempty" json:"format,omitempty" lopt:"fsformat" comment:"format 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" json:"wipe_filesystem,omitempty" lopt:"fswipe" comment:"wipe file system at boot"`
Label string `yaml:"label,omitempty" json:"label,omitempty" comment:"the label of the filesystem"`
Uuid string `yaml:"uuid,omitempty" json:"uuid,omitempty" comment:"the uuid of the filesystem"`
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" json:"mount_options,omitempty" comment:"any special options to be passed to the mount command"`
}
type Resource interface{}

View File

@@ -356,7 +356,7 @@ func (node *Node) updatePrimaryNetDev() {
}
sort.Strings(keys)
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.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 {
wwlog.SetLogLevel(level)
}
} else {
wwlog.SetLogLevel(wwlog.INFO)
}
loginit = true

View File

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

View File

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

View File

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

View File

@@ -227,8 +227,8 @@ be rebuilt into a bootable static object automatically. (To skip the automatic
image rebuild, specify ``--build=false``.)
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`_
section.
additional check to confirm if the users are in sync as described in the
:ref:`Syncuser` section.
Specifying a Prompt
-------------------

View File

@@ -27,6 +27,7 @@ Welcome to the Warewulf User Guide!
Security <server/security>
Bootloaders <server/bootloaders>
Upgrading Warewulf <server/upgrade>
REST API <server/api>
.. toctree::
: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.host_keys
.. _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,
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
========

View File

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