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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
32
Makefile
32
Makefile
@@ -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
3
etc/auth.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
users:
|
||||
- name: admin
|
||||
password hash: "" # generate a password hash with `mkpasswd --method=bcrypt`
|
||||
13
go.mod
13
go.mod
@@ -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
38
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
50
internal/pkg/config/api.go
Normal file
50
internal/pkg/config/api.go
Normal 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)
|
||||
}
|
||||
72
internal/pkg/config/authentication.go
Normal file
72
internal/pkg/config/authentication.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
104
internal/pkg/node/queries.go
Normal file
104
internal/pkg/node/queries.go
Normal 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
|
||||
}
|
||||
395
internal/pkg/node/queries_test.go
Normal file
395
internal/pkg/node/queries_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
80
internal/pkg/warewulfd/api/api.go
Normal file
80
internal/pkg/warewulfd/api/api.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/swaggest/openapi-go/openapi3"
|
||||
"github.com/swaggest/rest/nethttp"
|
||||
"github.com/swaggest/rest/web"
|
||||
swgui "github.com/swaggest/swgui/v5emb"
|
||||
|
||||
"github.com/warewulf/warewulf/internal/pkg/config"
|
||||
"github.com/warewulf/warewulf/internal/pkg/version"
|
||||
)
|
||||
|
||||
func Handler(auth *config.Authentication, allowedNets []net.IPNet) *web.Service {
|
||||
api := web.NewService(openapi3.NewReflector())
|
||||
|
||||
api.OpenAPISchema().SetTitle("Warewulf v4 API")
|
||||
api.OpenAPISchema().SetDescription("This service provides an API to a Warewulf v4 server.")
|
||||
api.OpenAPISchema().SetVersion(version.GetVersion())
|
||||
|
||||
api.Route("/api/nodes", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(auth, allowedNets))
|
||||
|
||||
r.Method(http.MethodGet, "/", nethttp.NewHandler(getNodes()))
|
||||
r.Method(http.MethodGet, "/{id}", nethttp.NewHandler(getNodeByID()))
|
||||
r.Method(http.MethodGet, "/{id}/raw", nethttp.NewHandler(getRawNodeByID()))
|
||||
r.Method(http.MethodPut, "/{id}", nethttp.NewHandler(addNode()))
|
||||
r.Method(http.MethodDelete, "/{id}", nethttp.NewHandler(deleteNode()))
|
||||
r.Method(http.MethodPatch, "/{id}", nethttp.NewHandler(updateNode()))
|
||||
r.Method(http.MethodGet, "/{id}/fields", nethttp.NewHandler(getNodeFields()))
|
||||
r.Method(http.MethodPost, "/overlays/build", nethttp.NewHandler(buildAllOverlays()))
|
||||
r.Method(http.MethodPost, "/{id}/overlays/build", nethttp.NewHandler(buildOverlays()))
|
||||
})
|
||||
})
|
||||
|
||||
api.Route("/api/profiles", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(auth, allowedNets))
|
||||
|
||||
r.Method(http.MethodGet, "/", nethttp.NewHandler(getProfiles()))
|
||||
r.Method(http.MethodGet, "/{id}", nethttp.NewHandler(getProfileByID()))
|
||||
r.Method(http.MethodPut, "/{id}", nethttp.NewHandler(addProfile()))
|
||||
r.Method(http.MethodPatch, "/{id}", nethttp.NewHandler(updateProfile()))
|
||||
r.Method(http.MethodDelete, "/{id}", nethttp.NewHandler(deleteProfile()))
|
||||
})
|
||||
})
|
||||
|
||||
api.Route("/api/images", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(auth, allowedNets))
|
||||
|
||||
r.Method(http.MethodGet, "/", nethttp.NewHandler(getImages()))
|
||||
r.Method(http.MethodGet, "/{name}", nethttp.NewHandler(getImageByName()))
|
||||
r.Method(http.MethodPost, "/{name}/import", nethttp.NewHandler(importImage()))
|
||||
r.Method(http.MethodPatch, "/{name}", nethttp.NewHandler(updateImage()))
|
||||
r.Method(http.MethodPost, "/{name}/build", nethttp.NewHandler(buildImage()))
|
||||
r.Method(http.MethodDelete, "/{name}", nethttp.NewHandler(deleteImage()))
|
||||
})
|
||||
})
|
||||
|
||||
api.Route("/api/overlays", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(auth, allowedNets))
|
||||
|
||||
r.Method(http.MethodGet, "/", nethttp.NewHandler(getOverlays()))
|
||||
r.Method(http.MethodGet, "/{name}", nethttp.NewHandler(getOverlayByName()))
|
||||
r.Method(http.MethodGet, "/{name}/file", nethttp.NewHandler(getOverlayFile()))
|
||||
r.Method(http.MethodPut, "/{name}", nethttp.NewHandler(createOverlay()))
|
||||
r.Method(http.MethodDelete, "/{name}", nethttp.NewHandler(deleteOverlay()))
|
||||
})
|
||||
})
|
||||
|
||||
api.Docs("/api/docs", swgui.New)
|
||||
|
||||
return api
|
||||
}
|
||||
56
internal/pkg/warewulfd/api/authentication.go
Normal file
56
internal/pkg/warewulfd/api/authentication.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/warewulf/warewulf/internal/pkg/config"
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
)
|
||||
|
||||
func AuthMiddleware(auth *config.Authentication, allowedNets []net.IPNet) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wwlog.Debug("allowed subnets: %v", allowedNets)
|
||||
wwlog.Debug("remote address: %v", r.RemoteAddr)
|
||||
fromAllowedNet := false
|
||||
if ipStr, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid remote address: %v", r.RemoteAddr), http.StatusForbidden)
|
||||
}
|
||||
for _, allowedNet := range allowedNets {
|
||||
if allowedNet.Contains(ip) {
|
||||
fromAllowedNet = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !fromAllowedNet {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.Error(w, fmt.Sprintf("Invalid remote address: %v", r.RemoteAddr), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := auth.Authenticate(username, password)
|
||||
if err != nil {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
213
internal/pkg/warewulfd/api/image.go
Normal file
213
internal/pkg/warewulfd/api/image.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/swaggest/usecase"
|
||||
"github.com/swaggest/usecase/status"
|
||||
image_api "github.com/warewulf/warewulf/internal/pkg/api/image"
|
||||
"github.com/warewulf/warewulf/internal/pkg/api/routes/wwapiv1"
|
||||
"github.com/warewulf/warewulf/internal/pkg/image"
|
||||
"github.com/warewulf/warewulf/internal/pkg/kernel"
|
||||
"github.com/warewulf/warewulf/internal/pkg/node"
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
Kernels []string `json:"kernels"`
|
||||
Size int `json:"size"`
|
||||
BuildTime int64 `json:"buildtime"`
|
||||
Writable bool `json:"writable"`
|
||||
}
|
||||
|
||||
func NewImage(name string) *Image {
|
||||
c := new(Image)
|
||||
c.Kernels = []string{}
|
||||
for _, k := range kernel.FindKernels(name) {
|
||||
c.Kernels = append(c.Kernels, k.Path)
|
||||
}
|
||||
c.Size = image.ImageSize(name)
|
||||
modTime := image.ImageModTime(name)
|
||||
if modTime.IsZero() {
|
||||
c.BuildTime = 0
|
||||
} else {
|
||||
c.BuildTime = modTime.Unix()
|
||||
}
|
||||
c.Writable = image.IsWriteAble(name)
|
||||
return c
|
||||
}
|
||||
|
||||
func getImages() usecase.Interactor {
|
||||
u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, output *map[string]*Image) error {
|
||||
wwlog.Debug("api.getImages()")
|
||||
m := make(map[string]*Image)
|
||||
if names, err := image.ListSources(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
for _, name := range names {
|
||||
m[name] = NewImage(name)
|
||||
}
|
||||
*output = m
|
||||
return nil
|
||||
}
|
||||
})
|
||||
u.SetTitle("Get images")
|
||||
u.SetDescription("Get all node images")
|
||||
u.SetTags("Image")
|
||||
return u
|
||||
}
|
||||
|
||||
func getImageByName() usecase.Interactor {
|
||||
type getImageByNameInput struct {
|
||||
Name string `path:"name" required:"true" description:"Name of image to add"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input getImageByNameInput, output *Image) error {
|
||||
wwlog.Debug("api.getImageByName(Name:%v)", input.Name)
|
||||
if !image.ValidSource(input.Name) {
|
||||
return status.Wrap(fmt.Errorf("image not found: %v", input.Name), status.NotFound)
|
||||
} else {
|
||||
*output = *NewImage(input.Name)
|
||||
return nil
|
||||
}
|
||||
})
|
||||
u.SetTitle("Get an image")
|
||||
u.SetDescription("Get a node image by its name")
|
||||
u.SetTags("Image")
|
||||
return u
|
||||
}
|
||||
|
||||
func importImage() usecase.Interactor {
|
||||
type importImageInput struct {
|
||||
Name string `path:"name" required:"true" description:"Name of image to import"`
|
||||
URI string `json:"uri" required:"true" description:"OCI registry URI to import image definition from"`
|
||||
NoHttps bool `json:"nohttps" default:"false" description:"Use http, rather than https, to communicate with the registry, default:'false'"`
|
||||
User string `json:"user" description:"Username for the registry, if needed"`
|
||||
Password string `json:"password" description:"Password for the registry, if needed"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input importImageInput, output *Image) error {
|
||||
wwlog.Debug("api.importImage(Name:%v, URI:%v, NoHttps:%v, User:%v, Password:[redacted])",
|
||||
input.Name, input.URI, input.NoHttps, input.User)
|
||||
if !strings.HasPrefix(input.URI, "docker://") {
|
||||
return status.Wrap(fmt.Errorf("missing docker:// prefix: %s", input.URI), status.InvalidArgument)
|
||||
}
|
||||
|
||||
if !image.ValidName(input.Name) {
|
||||
return status.Wrap(fmt.Errorf("name contains illegal characters: %s", input.Name), status.InvalidArgument)
|
||||
}
|
||||
|
||||
if sctx, err := image_api.GetSystemContext(input.NoHttps, input.User, input.Password, ""); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if err := image.ImportDocker(input.URI, input.Name, sctx); err != nil {
|
||||
return err
|
||||
}
|
||||
*output = *NewImage(input.Name)
|
||||
return nil
|
||||
}
|
||||
})
|
||||
u.SetTitle("Import an image")
|
||||
u.SetDescription("Import a node image from an OCI registry")
|
||||
u.SetTags("Image")
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func deleteImage() usecase.Interactor {
|
||||
type deleteImageInput struct {
|
||||
Name string `path:"name" required:"true" description:"Name of image to delete"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input deleteImageInput, output *Image) error {
|
||||
wwlog.Debug("api.deleteImage(Name:%v)", input.Name)
|
||||
if image.ValidSource(input.Name) {
|
||||
*output = *NewImage(input.Name)
|
||||
}
|
||||
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
nodesCount := len(registry.ListNodesUsingImage(input.Name))
|
||||
profilesCount := len(registry.ListProfilesUsingImage(input.Name))
|
||||
if nodesCount > 0 || profilesCount > 0 {
|
||||
return status.Wrap(fmt.Errorf(
|
||||
"image '%s' is in use by %v nodes and %v profiles", input.Name, nodesCount, profilesCount),
|
||||
status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
cdp := &wwapiv1.ImageDeleteParameter{
|
||||
ImageNames: []string{input.Name},
|
||||
}
|
||||
|
||||
return image_api.ImageDelete(cdp)
|
||||
})
|
||||
u.SetTitle("Delete an image")
|
||||
u.SetDescription("Delete an existing node image")
|
||||
u.SetTags("Image")
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func updateImage() usecase.Interactor {
|
||||
type renameImageInput struct {
|
||||
Name string `path:"name" required:"true" description:"Name of image to update"`
|
||||
NewName string `json:"name" description:"New name to rename the image to"`
|
||||
Build bool `query:"build" default:"true" description:"Build the image image after renaming, default:'true'"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input renameImageInput, output *Image) error {
|
||||
wwlog.Debug("api.updateImage(Name:%v, NewName:%v, Build:%v)", input.Name, input.NewName, input.Build)
|
||||
name := input.Name
|
||||
if input.NewName != "" {
|
||||
crp := &wwapiv1.ImageRenameParameter{
|
||||
ImageName: input.Name,
|
||||
TargetName: input.NewName,
|
||||
Build: input.Build,
|
||||
}
|
||||
|
||||
if err := image_api.ImageRename(crp); err != nil {
|
||||
return err
|
||||
}
|
||||
name = input.NewName
|
||||
}
|
||||
|
||||
*output = *NewImage(name)
|
||||
return nil
|
||||
})
|
||||
u.SetTitle("Update or rename an image")
|
||||
u.SetDescription("Update or rename an existing node image")
|
||||
u.SetTags("Image")
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func buildImage() usecase.Interactor {
|
||||
type buildImageInput struct {
|
||||
Name string `path:"name" required:"true" description:"Name of image to build"`
|
||||
Force bool `query:"force" default:"false" description:"Build the image image even if it appears unnecessary, default:'false'"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input buildImageInput, output *Image) error {
|
||||
wwlog.Debug("api.buildImage(Name:%v, Force:%v)", input.Name, input.Force)
|
||||
cbp := &wwapiv1.ImageBuildParameter{
|
||||
ImageNames: []string{input.Name},
|
||||
Force: input.Force,
|
||||
}
|
||||
|
||||
if err := image_api.ImageBuild(cbp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*output = *NewImage(input.Name)
|
||||
return nil
|
||||
})
|
||||
u.SetTitle("Build an image")
|
||||
u.SetDescription("Build a node image")
|
||||
u.SetTags("Image")
|
||||
|
||||
return u
|
||||
}
|
||||
144
internal/pkg/warewulfd/api/image_test.go
Normal file
144
internal/pkg/warewulfd/api/image_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/warewulf/warewulf/internal/pkg/config"
|
||||
"github.com/warewulf/warewulf/internal/pkg/testenv"
|
||||
)
|
||||
|
||||
func TestImageAPI(t *testing.T) {
|
||||
env := testenv.New(t)
|
||||
defer env.RemoveAll()
|
||||
|
||||
authData := `
|
||||
users:
|
||||
- name: admin
|
||||
password hash: $2b$05$5QVWDpiWE7L4SDL9CYdi3O/l6HnbNOLoXgY2sa1bQQ7aSBKdSqvsC
|
||||
`
|
||||
auth := config.NewAuthentication()
|
||||
err := auth.ParseFromRaw([]byte(authData))
|
||||
assert.NoError(t, err)
|
||||
|
||||
allowedNets := []net.IPNet{
|
||||
{
|
||||
IP: net.IPv4(127, 0, 0, 0),
|
||||
Mask: net.CIDRMask(8, 32),
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(Handler(auth, allowedNets))
|
||||
defer srv.Close()
|
||||
env.WriteFile(path.Join(testenv.WWChrootdir, "test-image/rootfs/file"), `test`)
|
||||
|
||||
t.Run("test no authentication", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/images", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.Equal(t, resp.StatusCode, http.StatusUnauthorized)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Unauthorized\n", string(body))
|
||||
})
|
||||
|
||||
t.Run("test get all images", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/images", nil)
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "admin")
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"test-image": {"kernels":[], "size":0, "buildtime":0, "writable":true}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("test get single image", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/images/test-image", nil)
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "admin")
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"kernels":[] ,"size":0, "buildtime":0, "writable":true}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("test build image", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/images/test-image/build?force=true&default=true", nil)
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "admin")
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
|
||||
var bodyData map[string]interface{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(body), &bodyData))
|
||||
assert.True(t, bodyData["buildtime"].(float64) > 0.0)
|
||||
|
||||
bodyData["buildtime"] = 0.0
|
||||
assert.Equal(t, map[string]interface{}{"kernels": []interface{}{}, "size": 512.0, "buildtime": 0.0, "writable": true}, bodyData)
|
||||
})
|
||||
|
||||
t.Run("test rename image", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPatch, srv.URL+"/api/images/test-image?build=true", bytes.NewBuffer([]byte(`{"name": "new-image"}`)))
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "admin")
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
|
||||
var bodyData map[string]interface{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(body), &bodyData))
|
||||
assert.True(t, bodyData["buildtime"].(float64) > 0.0)
|
||||
|
||||
bodyData["buildtime"] = 0.0
|
||||
assert.Equal(t, map[string]interface{}{"kernels": []interface{}{}, "size": 512.0, "buildtime": 0.0, "writable": true}, bodyData)
|
||||
})
|
||||
|
||||
t.Run("test delete image", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodDelete, srv.URL+"/api/images/new-image", nil)
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "admin")
|
||||
|
||||
// send request
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// validate the resp
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.Equal(t, resp.StatusCode, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var bodyData map[string]interface{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(body), &bodyData))
|
||||
assert.True(t, bodyData["buildtime"].(float64) > 0.0)
|
||||
|
||||
bodyData["buildtime"] = 0.0
|
||||
assert.Equal(t, map[string]interface{}{"kernels": []interface{}{}, "size": 512.0, "buildtime": 0.0, "writable": true}, bodyData)
|
||||
})
|
||||
}
|
||||
303
internal/pkg/warewulfd/api/node.go
Normal file
303
internal/pkg/warewulfd/api/node.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sort"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/swaggest/usecase"
|
||||
"github.com/swaggest/usecase/status"
|
||||
"github.com/warewulf/warewulf/internal/pkg/image"
|
||||
"github.com/warewulf/warewulf/internal/pkg/node"
|
||||
"github.com/warewulf/warewulf/internal/pkg/overlay"
|
||||
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
)
|
||||
|
||||
func getNodes() usecase.Interactor {
|
||||
u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, output *map[string]*node.Node) error {
|
||||
wwlog.Debug("api.getNodes()")
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
nodeMap := make(map[string]*node.Node)
|
||||
if nodeList, err := registry.FindAllNodes(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
for i := range nodeList {
|
||||
nodeMap[nodeList[i].Id()] = &nodeList[i]
|
||||
}
|
||||
*output = nodeMap
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
u.SetTitle("Get nodes")
|
||||
u.SetDescription("Get all nodes, including field values from associated profiles.")
|
||||
u.SetTags("Node")
|
||||
return u
|
||||
}
|
||||
|
||||
func getNodeByID() usecase.Interactor {
|
||||
type getNodeByIDInput struct {
|
||||
ID string `path:"id" required:"true" description:"ID of node to get"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input getNodeByIDInput, output *node.Node) error {
|
||||
wwlog.Debug("api.getNodeByID(ID:%v)", input.ID)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if node_, err := registry.GetNode(input.ID); err != nil {
|
||||
return status.Wrap(fmt.Errorf("node not found: %v (%v)", input.ID, err), status.NotFound)
|
||||
} else {
|
||||
*output = node_
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
u.SetTitle("Get a node")
|
||||
u.SetDescription("Get a node by its ID, including field values from associated profiles.")
|
||||
u.SetTags("Node")
|
||||
u.SetExpectedErrors(status.NotFound)
|
||||
return u
|
||||
}
|
||||
|
||||
func getRawNodeByID() usecase.Interactor {
|
||||
type getNodeByIDInput struct {
|
||||
ID string `path:"id" required:"true" description:"ID of node to get"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input getNodeByIDInput, output *node.Node) error {
|
||||
wwlog.Debug("api.getRawNodeByID(ID:%v)", input.ID)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if node_, ok := registry.Nodes[input.ID]; !ok {
|
||||
return status.Wrap(fmt.Errorf("node not found: %v", input.ID), status.NotFound)
|
||||
} else {
|
||||
*output = *node_
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
u.SetTitle("Get a raw node")
|
||||
u.SetDescription("Get a node by its ID, without field values from associated profiles.")
|
||||
u.SetTags("Node")
|
||||
u.SetExpectedErrors(status.NotFound)
|
||||
return u
|
||||
}
|
||||
|
||||
func getNodeFields() usecase.Interactor {
|
||||
type getNodeByIDInput struct {
|
||||
ID string `path:"id" required:"true" description:"ID of node from which to retrieve fields"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input getNodeByIDInput, output *[]node.Field) error {
|
||||
wwlog.Debug("api.getNodeFields(ID:%v)", input.ID)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if n, fields, err := registry.MergeNode(input.ID); err != nil {
|
||||
return status.Wrap(fmt.Errorf("node not found: %v (%v)", input.ID, err), status.NotFound)
|
||||
} else {
|
||||
*output = fields.List(n)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
u.SetTitle("Get node fields")
|
||||
u.SetDescription("Get the fields and values of a node, indicating which profiles each field originates from.")
|
||||
u.SetTags("Node")
|
||||
u.SetExpectedErrors(status.NotFound)
|
||||
return u
|
||||
}
|
||||
|
||||
func addNode() usecase.Interactor {
|
||||
type addNodeInput struct {
|
||||
ID string `path:"id" required:"true" description:"ID of node to be added"`
|
||||
Node node.Node `json:"node" required:"true" description:"Field values in JSON format for added node"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input addNodeInput, output *node.Node) error {
|
||||
wwlog.Debug("api.addNode(ID:%v, Node:%+v)", input.ID, input.Node)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
for _, profile := range input.Node.Profiles {
|
||||
if _, ok := registry.NodeProfiles[profile]; !ok {
|
||||
return status.Wrap(fmt.Errorf("profile '%s' does not exist", profile), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
if input.Node.ImageName != "" && !image.ValidSource(input.Node.ImageName) {
|
||||
return status.Wrap(fmt.Errorf("image '%s' does not exist", input.Node.ImageName), status.InvalidArgument)
|
||||
}
|
||||
for _, overlay_ := range input.Node.SystemOverlay {
|
||||
if !overlay.GetOverlay(overlay_).Exists() {
|
||||
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
for _, overlay_ := range input.Node.RuntimeOverlay {
|
||||
if !overlay.GetOverlay(overlay_).Exists() {
|
||||
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
registry.Nodes[input.ID] = &input.Node
|
||||
if err := registry.Persist(); err != nil {
|
||||
return err
|
||||
}
|
||||
warewulfd.Reload()
|
||||
*output = *(registry.Nodes[input.ID])
|
||||
return nil
|
||||
}
|
||||
})
|
||||
u.SetTitle("Add a node")
|
||||
u.SetDescription("Add a new node.")
|
||||
u.SetTags("Node")
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func deleteNode() usecase.Interactor {
|
||||
type deleteNodeInput struct {
|
||||
ID string `path:"id" required:"true" description:"ID of node to delete"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input deleteNodeInput, output *node.Node) error {
|
||||
wwlog.Debug("api.deleteNode(ID:%v)", input.ID)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if node, ok := registry.Nodes[input.ID]; ok {
|
||||
*output = *node
|
||||
}
|
||||
if err := registry.DelNode(input.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registry.Persist(); err != nil {
|
||||
return err
|
||||
}
|
||||
warewulfd.Reload()
|
||||
return nil
|
||||
}
|
||||
})
|
||||
u.SetTitle("Delete a node")
|
||||
u.SetDescription("Delete an existing node.")
|
||||
u.SetTags("Node")
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func updateNode() usecase.Interactor {
|
||||
type updateNodeInput struct {
|
||||
ID string `path:"id" description:"ID of node to update"`
|
||||
Node node.Node `json:"node" required:"true" description:"Field values in JSON format to update on node"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input updateNodeInput, output *node.Node) error {
|
||||
wwlog.Debug("api.updateNode(ID:%v, Node:%+v)", input.ID, input.Node)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
for _, profile := range input.Node.Profiles {
|
||||
if _, ok := registry.NodeProfiles[profile]; !ok {
|
||||
return status.Wrap(fmt.Errorf("profile '%s' does not exist", profile), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
if input.Node.ImageName != "" && !image.ValidSource(input.Node.ImageName) {
|
||||
return status.Wrap(fmt.Errorf("image '%s' does not exist", input.Node.ImageName), status.InvalidArgument)
|
||||
}
|
||||
for _, overlay_ := range input.Node.SystemOverlay {
|
||||
if !overlay.GetOverlay(overlay_).Exists() {
|
||||
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
for _, overlay_ := range input.Node.RuntimeOverlay {
|
||||
if !overlay.GetOverlay(overlay_).Exists() {
|
||||
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
if nodePtr, err := registry.GetNodeOnlyPtr(input.ID); err != nil {
|
||||
return status.Wrap(err, status.NotFound)
|
||||
} else {
|
||||
if err := mergo.MergeWithOverwrite(nodePtr, &input.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registry.Persist(); err != nil {
|
||||
return err
|
||||
}
|
||||
warewulfd.Reload()
|
||||
*output = *nodePtr
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
u.SetTitle("Update a node")
|
||||
u.SetDescription("Update an existing node.")
|
||||
u.SetTags("Node")
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func buildAllOverlays() usecase.Interactor {
|
||||
u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, output *[]string) error {
|
||||
wwlog.Debug("api.buildAllOverlays()")
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if nodes, err := registry.FindAllNodes(); err != nil {
|
||||
return fmt.Errorf("could not get node list: %w", err)
|
||||
} else {
|
||||
ret := make([]string, len(nodes))
|
||||
for i := range nodes {
|
||||
ret[i] = nodes[i].Id()
|
||||
}
|
||||
sort.Strings(ret)
|
||||
if err := overlay.BuildAllOverlays(nodes, nodes, runtime.NumCPU()); err != nil {
|
||||
return err
|
||||
}
|
||||
*output = ret
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
u.SetTitle("Build all overlay images")
|
||||
u.SetDescription("Build system and runtime overlay images for all nodes.")
|
||||
u.SetTags("Node")
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func buildOverlays() usecase.Interactor {
|
||||
type buildOverlayInput struct {
|
||||
ID string `path:"id" description:"ID of node to build overlay images for"`
|
||||
}
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input *buildOverlayInput, output *string) error {
|
||||
wwlog.Debug("api.buildOverlays()")
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
nodes, err := registry.FindAllNodes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if node_, err := registry.GetNode(input.ID); err != nil {
|
||||
return status.Wrap(err, status.NotFound)
|
||||
} else {
|
||||
if err := overlay.BuildAllOverlays([]node.Node{node_}, nodes, runtime.NumCPU()); err != nil {
|
||||
return err
|
||||
}
|
||||
*output = input.ID
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
u.SetTitle("Build overlay images for a node")
|
||||
u.SetDescription("Build system and runtime overlay images for a node.")
|
||||
u.SetTags("Node")
|
||||
|
||||
return u
|
||||
}
|
||||
176
internal/pkg/warewulfd/api/node_test.go
Normal file
176
internal/pkg/warewulfd/api/node_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/warewulf/warewulf/internal/pkg/testenv"
|
||||
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
|
||||
)
|
||||
|
||||
func TestNodeAPI(t *testing.T) {
|
||||
warewulfd.SetNoDaemon()
|
||||
env := testenv.New(t)
|
||||
defer env.RemoveAll()
|
||||
|
||||
allowedNets := []net.IPNet{
|
||||
{
|
||||
IP: net.IPv4(127, 0, 0, 0),
|
||||
Mask: net.CIDRMask(8, 32),
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(Handler(nil, allowedNets))
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("add a node", func(t *testing.T) {
|
||||
// prepareration
|
||||
|
||||
testNode := `{
|
||||
"node":{
|
||||
"kernel": {
|
||||
"version": "v1.0.0",
|
||||
"args": ["kernel-args"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
req, err := http.NewRequest(http.MethodPut, srv.URL+"/api/nodes/test", bytes.NewBuffer([]byte(testNode)))
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("read all nodes", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/nodes", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// send request
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// validate the resp
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"node1": {}, "test": {"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("get one specific node", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/nodes/test", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// send request
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// validate the resp
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("update the node", func(t *testing.T) {
|
||||
updateNode := `{
|
||||
"node":{
|
||||
"kernel": {
|
||||
"version": "v1.0.1-newversion"
|
||||
}
|
||||
}
|
||||
}`
|
||||
req, err := http.NewRequest(http.MethodPatch, srv.URL+"/api/nodes/test", bytes.NewBuffer([]byte(updateNode)))
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("get one specific node (again)", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/nodes/test", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("get one specific (raw) node", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/nodes/test/raw", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("test build all nodes overlays", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/nodes/overlays/build", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `["node1", "test"]`, string(body))
|
||||
})
|
||||
|
||||
t.Run("test build one node's overlays", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/nodes/test/overlays/build", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `"test"`, string(body))
|
||||
})
|
||||
|
||||
t.Run("test delete nodes", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodDelete, srv.URL+"/api/nodes/test", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
}
|
||||
234
internal/pkg/warewulfd/api/overlay.go
Normal file
234
internal/pkg/warewulfd/api/overlay.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/swaggest/usecase"
|
||||
"github.com/swaggest/usecase/status"
|
||||
"github.com/warewulf/warewulf/internal/pkg/node"
|
||||
"github.com/warewulf/warewulf/internal/pkg/overlay"
|
||||
"github.com/warewulf/warewulf/internal/pkg/util"
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
)
|
||||
|
||||
type OverlayResponse struct {
|
||||
Files []string `json:"files"`
|
||||
Site bool `json:"site"`
|
||||
}
|
||||
|
||||
func NewOverlayResponse(name string) *OverlayResponse {
|
||||
o := new(OverlayResponse)
|
||||
o.Files = []string{}
|
||||
if files, err := overlay.OverlayGetFiles(name); err == nil {
|
||||
o.Files = files
|
||||
}
|
||||
o.Site = overlay.GetOverlay(name).IsSiteOverlay()
|
||||
return o
|
||||
}
|
||||
|
||||
func getOverlays() usecase.Interactor {
|
||||
u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, output *map[string]*OverlayResponse) error {
|
||||
wwlog.Debug("api.getOverlays()")
|
||||
m := make(map[string]*OverlayResponse)
|
||||
names := overlay.FindOverlays()
|
||||
for _, name := range names {
|
||||
m[name] = NewOverlayResponse(name)
|
||||
}
|
||||
*output = m
|
||||
return nil
|
||||
})
|
||||
u.SetTitle("Get overlays")
|
||||
u.SetDescription("Get all overlays.")
|
||||
u.SetTags("Overlay")
|
||||
return u
|
||||
}
|
||||
|
||||
func getOverlayByName() usecase.Interactor {
|
||||
type getOverlayByNameInput struct {
|
||||
Name string `path:"name" required:"true" description:"Name of overlay to get"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input getOverlayByNameInput, output *OverlayResponse) error {
|
||||
wwlog.Debug("api.getOverlayByName(Name:%v)", input.Name)
|
||||
if !overlay.GetOverlay(input.Name).Exists() {
|
||||
return status.Wrap(fmt.Errorf("overlay not found: %v", input.Name), status.NotFound)
|
||||
} else {
|
||||
*output = *NewOverlayResponse(input.Name)
|
||||
return nil
|
||||
}
|
||||
})
|
||||
u.SetTitle("Get an overlay")
|
||||
u.SetDescription("Get an overlay by its name.")
|
||||
u.SetTags("Overlay")
|
||||
return u
|
||||
}
|
||||
|
||||
type OverlayFile struct {
|
||||
Overlay string `json:"overlay"`
|
||||
Path string `json:"path"`
|
||||
Contents string `json:"contents"`
|
||||
rendered bool
|
||||
}
|
||||
|
||||
func (of *OverlayFile) FullPath() string {
|
||||
return path.Join(overlay.GetOverlay(of.Overlay).Rootfs(), of.Path)
|
||||
}
|
||||
|
||||
func (of *OverlayFile) Exists() bool {
|
||||
return overlay.GetOverlay(of.Overlay).Exists() && util.IsFile(of.FullPath())
|
||||
}
|
||||
|
||||
func (of *OverlayFile) readContents() (string, error) {
|
||||
f, err := os.ReadFile(of.FullPath())
|
||||
return string(f), err
|
||||
}
|
||||
|
||||
func (of *OverlayFile) renderContents(nodeName string) (string, error) {
|
||||
if !(path.Ext(of.Path) == ".ww") {
|
||||
return "", fmt.Errorf("'%s' does not end with '.ww'", of.Path)
|
||||
}
|
||||
|
||||
if of.rendered {
|
||||
return "", fmt.Errorf("already rendered")
|
||||
}
|
||||
|
||||
registry, regErr := node.New()
|
||||
if regErr != nil {
|
||||
return "", regErr
|
||||
}
|
||||
|
||||
renderNode, nodeErr := registry.GetNode(nodeName)
|
||||
if nodeErr != nil {
|
||||
return "", nodeErr
|
||||
}
|
||||
|
||||
allNodes, allNodesErr := registry.FindAllNodes()
|
||||
if allNodesErr != nil {
|
||||
return "", allNodesErr
|
||||
}
|
||||
|
||||
tstruct, structErr := overlay.InitStruct(of.Overlay, renderNode, allNodes)
|
||||
if structErr != nil {
|
||||
return "", structErr
|
||||
}
|
||||
tstruct.BuildSource = of.Path
|
||||
|
||||
buffer, _, _, renderErr := overlay.RenderTemplateFile(of.FullPath(), tstruct)
|
||||
if renderErr != nil {
|
||||
return "", renderErr
|
||||
}
|
||||
|
||||
return buffer.String(), nil
|
||||
}
|
||||
|
||||
func NewOverlayFile(name string, path string, renderNodeName string) (*OverlayFile, error) {
|
||||
of := new(OverlayFile)
|
||||
of.Overlay = name
|
||||
of.Path = path
|
||||
if renderNodeName == "" {
|
||||
if contents, err := of.readContents(); err != nil {
|
||||
return of, err
|
||||
} else {
|
||||
of.Contents = contents
|
||||
}
|
||||
} else {
|
||||
if contents, err := of.renderContents(renderNodeName); err != nil {
|
||||
return of, err
|
||||
} else {
|
||||
of.Contents = contents
|
||||
}
|
||||
}
|
||||
return of, nil
|
||||
}
|
||||
|
||||
func getOverlayFile() usecase.Interactor {
|
||||
type getOverlayFileInput struct {
|
||||
Name string `path:"name" required:"true" description:"Name of overlay to get a file from"`
|
||||
Path string `query:"path" required:"true" description:"Path to file to get from an overlay"`
|
||||
Node string `query:"render" description:"ID of node to render a template for"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input getOverlayFileInput, output *OverlayFile) error {
|
||||
wwlog.Debug("api.getOverlayFile(Name:%v, Path:%v, Node:%v)", input.Name, input.Path, input.Node)
|
||||
if input.Path == "" {
|
||||
return status.Wrap(fmt.Errorf("must specify a path"), status.InvalidArgument)
|
||||
}
|
||||
|
||||
if relPath, err := url.QueryUnescape(input.Path); err != nil {
|
||||
return fmt.Errorf("failed to decode path: %v: %w", input.Path, err)
|
||||
} else {
|
||||
if overlayFile, err := NewOverlayFile(input.Name, relPath, input.Node); err != nil {
|
||||
return fmt.Errorf("unable to read overlay file %v: %v: %w", input.Name, relPath, err)
|
||||
} else {
|
||||
*output = *overlayFile
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
u.SetTitle("Get a file from an overlay")
|
||||
u.SetDescription("Get a file from an overlay from the overlay name and file path, optionally rendered for a given node.")
|
||||
u.SetTags("Overlay")
|
||||
return u
|
||||
}
|
||||
|
||||
func createOverlay() usecase.Interactor {
|
||||
type createOverlayInput struct {
|
||||
Name string `path:"name" required:"true" description:"Name of overlay to create"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input createOverlayInput, output *OverlayResponse) error {
|
||||
wwlog.Debug("api.createOverlay(Name:%v)", input.Name)
|
||||
newOverlay := overlay.GetSiteOverlay(input.Name)
|
||||
if err := newOverlay.Create(); err != nil {
|
||||
return err
|
||||
}
|
||||
*output = *NewOverlayResponse(newOverlay.Name())
|
||||
return nil
|
||||
})
|
||||
u.SetTitle("Create an overlay")
|
||||
u.SetDescription("Create an overlay.")
|
||||
u.SetTags("Overlay")
|
||||
return u
|
||||
}
|
||||
|
||||
func deleteOverlay() usecase.Interactor {
|
||||
type deleteOverlayInput struct {
|
||||
Name string `path:"name" required:"true" description:"Name of overlay to delete"`
|
||||
Force bool `query:"force" default:"false" description:"Whether to delete a non-empty overlay, default:'false'"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input deleteOverlayInput, output *OverlayResponse) error {
|
||||
wwlog.Debug("api.deleteOverlay(Name:%v, Force:%v)", input.Name, input.Force)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
nodesCount := len(registry.ListNodesUsingOverlay(input.Name))
|
||||
profilesCount := len(registry.ListProfilesUsingOverlay(input.Name))
|
||||
if nodesCount > 0 || profilesCount > 0 {
|
||||
return status.Wrap(fmt.Errorf(
|
||||
"overlay '%s' is in use by %v nodes and %v profiles", input.Name, nodesCount, profilesCount),
|
||||
status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
*output = *NewOverlayResponse(input.Name)
|
||||
overlay_ := overlay.GetSiteOverlay(input.Name)
|
||||
if input.Force {
|
||||
if err := os.RemoveAll(overlay_.Path()); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := os.Remove(overlay_.Path()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
u.SetTitle("Delete an overlay")
|
||||
u.SetDescription("Delete an overlay.")
|
||||
u.SetTags("Overlay")
|
||||
return u
|
||||
}
|
||||
121
internal/pkg/warewulfd/api/overlay_test.go
Normal file
121
internal/pkg/warewulfd/api/overlay_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/warewulf/warewulf/internal/pkg/testenv"
|
||||
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
|
||||
)
|
||||
|
||||
func TestOverlayAPI(t *testing.T) {
|
||||
warewulfd.SetNoDaemon()
|
||||
env := testenv.New(t)
|
||||
defer env.RemoveAll()
|
||||
env.WriteFile("usr/share/warewulf/overlays/testoverlay/email.ww", `
|
||||
{{ if .Tags.email }}eMail: {{ .Tags.email }}{{else}} noMail{{- end }}
|
||||
`)
|
||||
|
||||
allowedNets := []net.IPNet{
|
||||
{
|
||||
IP: net.IPv4(127, 0, 0, 0),
|
||||
Mask: net.CIDRMask(8, 32),
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(Handler(nil, allowedNets))
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("get all overlays", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/overlays", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// send request
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// validate the resp
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"testoverlay":{"files":["/email.ww"], "site":false}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("get one specific overlay", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/overlays/testoverlay", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// send request
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// validate the resp
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"files":["/email.ww"], "site":false}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("get overlay file", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/overlays/testoverlay/file?path=email.ww", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// send request
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// validate the resp
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"overlay":"testoverlay","path":"email.ww","contents":"\n{{ if .Tags.email }}eMail: {{ .Tags.email }}{{else}} noMail{{- end }}\n"}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("create an overlay", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPut, srv.URL+"/api/overlays/test", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"files":null, "site":true}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("get all overlays", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/overlays", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"test":{"files":null, "site":true},"testoverlay":{"files":["/email.ww"], "site":false}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("test delete overlays", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodDelete, srv.URL+"/api/overlays/test?force=true", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.JSONEq(t, `{"files":null, "site":true}`, string(body))
|
||||
})
|
||||
}
|
||||
193
internal/pkg/warewulfd/api/profile.go
Normal file
193
internal/pkg/warewulfd/api/profile.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/swaggest/usecase"
|
||||
"github.com/swaggest/usecase/status"
|
||||
"github.com/warewulf/warewulf/internal/pkg/image"
|
||||
"github.com/warewulf/warewulf/internal/pkg/node"
|
||||
"github.com/warewulf/warewulf/internal/pkg/overlay"
|
||||
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
)
|
||||
|
||||
func getProfiles() usecase.Interactor {
|
||||
u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, output *map[string]*node.Profile) error {
|
||||
wwlog.Debug("api.getProfiles()")
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
*output = registry.NodeProfiles
|
||||
return nil
|
||||
}
|
||||
})
|
||||
u.SetTitle("Get profiles")
|
||||
u.SetDescription("Get all node profiles.")
|
||||
u.SetTags("Profile")
|
||||
return u
|
||||
}
|
||||
|
||||
func getProfileByID() usecase.Interactor {
|
||||
type getProfileByIDInput struct {
|
||||
ID string `path:"id" required:"true" description:"ID of profile to get"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input getProfileByIDInput, output *node.Profile) error {
|
||||
wwlog.Debug("api.getProfileByID(ID:%v)", input.ID)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if profile, err := registry.GetProfile(input.ID); err != nil {
|
||||
return status.Wrap(fmt.Errorf("profile not found: %v (%v)", input.ID, err), status.NotFound)
|
||||
} else {
|
||||
*output = profile
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
u.SetTitle("Get a profile")
|
||||
u.SetDescription("Get a node profile by its ID.")
|
||||
u.SetTags("Profile")
|
||||
u.SetExpectedErrors(status.NotFound)
|
||||
return u
|
||||
}
|
||||
|
||||
func addProfile() usecase.Interactor {
|
||||
type addProfileInput struct {
|
||||
ID string `path:"id" required:"true" description:"ID of profile to add"`
|
||||
Profile node.Profile `json:"profile" required:"true" description:"Field values in JSON format for added profile"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input addProfileInput, output *node.Profile) error {
|
||||
wwlog.Debug("api.addProfile(ID:%v, Profile:%+v)", input.ID, input.Profile)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
for _, profile := range input.Profile.Profiles {
|
||||
if _, ok := registry.NodeProfiles[profile]; !ok {
|
||||
return status.Wrap(fmt.Errorf("profile '%s' does not exist", profile), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
if input.Profile.ImageName != "" && !image.ValidSource(input.Profile.ImageName) {
|
||||
return status.Wrap(fmt.Errorf("image '%s' does not exist", input.Profile.ImageName), status.InvalidArgument)
|
||||
}
|
||||
for _, overlay_ := range input.Profile.SystemOverlay {
|
||||
if !overlay.GetOverlay(overlay_).Exists() {
|
||||
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
for _, overlay_ := range input.Profile.RuntimeOverlay {
|
||||
if !overlay.GetOverlay(overlay_).Exists() {
|
||||
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
registry.NodeProfiles[input.ID] = &input.Profile
|
||||
if err := registry.Persist(); err != nil {
|
||||
return err
|
||||
}
|
||||
warewulfd.Reload()
|
||||
*output = *(registry.NodeProfiles[input.ID])
|
||||
return nil
|
||||
}
|
||||
})
|
||||
u.SetTitle("Add a profile")
|
||||
u.SetDescription("Add a new node profile.")
|
||||
u.SetTags("Profile")
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func updateProfile() usecase.Interactor {
|
||||
type updateProfileInput struct {
|
||||
ID string `path:"id" required:"true" description:"ID of profile to update"`
|
||||
Profile node.Profile `json:"profile" required:"true" description:"Field values in JSON format to update on profile"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input updateProfileInput, output *node.Profile) error {
|
||||
wwlog.Debug("api.updateProfile(ID:%v, Profile:%+v)", input.ID, input.Profile)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
for _, profile := range input.Profile.Profiles {
|
||||
if _, ok := registry.NodeProfiles[profile]; !ok {
|
||||
return status.Wrap(fmt.Errorf("profile '%s' does not exist", profile), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
if input.Profile.ImageName != "" && !image.ValidSource(input.Profile.ImageName) {
|
||||
return status.Wrap(fmt.Errorf("image '%s' does not exist", input.Profile.ImageName), status.InvalidArgument)
|
||||
}
|
||||
for _, overlay_ := range input.Profile.SystemOverlay {
|
||||
if !overlay.GetOverlay(overlay_).Exists() {
|
||||
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
for _, overlay_ := range input.Profile.RuntimeOverlay {
|
||||
if !overlay.GetOverlay(overlay_).Exists() {
|
||||
return status.Wrap(fmt.Errorf("overlay '%s' does not exist", overlay_), status.InvalidArgument)
|
||||
}
|
||||
}
|
||||
if profilePtr, err := registry.GetProfilePtr(input.ID); err != nil {
|
||||
return status.Wrap(err, status.NotFound)
|
||||
} else {
|
||||
if err := mergo.MergeWithOverwrite(profilePtr, &input.Profile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registry.Persist(); err != nil {
|
||||
return err
|
||||
}
|
||||
warewulfd.Reload()
|
||||
*output = *profilePtr
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
u.SetTitle("Update a profile")
|
||||
u.SetDescription("Update an existing node profile.")
|
||||
u.SetTags("Profile")
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func deleteProfile() usecase.Interactor {
|
||||
type deleteProfileInput struct {
|
||||
ID string `path:"id" required:"true" description:"ID of profile to delete"`
|
||||
}
|
||||
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input deleteProfileInput, output *node.Profile) error {
|
||||
wwlog.Debug("api.deleteProfile(ID:%v)", input.ID)
|
||||
if registry, err := node.New(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if profile, ok := registry.NodeProfiles[input.ID]; ok {
|
||||
*output = *profile
|
||||
}
|
||||
|
||||
nodesCount := len(registry.ListNodesUsingProfile(input.ID))
|
||||
profilesCount := len(registry.ListProfilesUsingProfile(input.ID))
|
||||
if nodesCount > 0 || profilesCount > 0 {
|
||||
return status.Wrap(fmt.Errorf(
|
||||
"profile '%s' is in use by %v nodes and %v profiles", input.ID, nodesCount, profilesCount),
|
||||
status.InvalidArgument)
|
||||
}
|
||||
|
||||
if err := registry.DelProfile(input.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := registry.Persist(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
warewulfd.Reload()
|
||||
return nil
|
||||
}
|
||||
})
|
||||
u.SetTitle("Delete a profile")
|
||||
u.SetDescription("Delete an existing node profile.")
|
||||
u.SetTags("Profile")
|
||||
|
||||
return u
|
||||
}
|
||||
122
internal/pkg/warewulfd/api/profile_test.go
Normal file
122
internal/pkg/warewulfd/api/profile_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/warewulf/warewulf/internal/pkg/testenv"
|
||||
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
|
||||
)
|
||||
|
||||
func TestProfileAPI(t *testing.T) {
|
||||
warewulfd.SetNoDaemon()
|
||||
env := testenv.New(t)
|
||||
defer env.RemoveAll()
|
||||
|
||||
allowedNets := []net.IPNet{
|
||||
{
|
||||
IP: net.IPv4(127, 0, 0, 0),
|
||||
Mask: net.CIDRMask(8, 32),
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(Handler(nil, allowedNets))
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("get all profiles", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/profiles", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"default": {}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("add a new profile", func(t *testing.T) {
|
||||
testProfile := `{"profile": {"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}}`
|
||||
req, err := http.NewRequest(http.MethodPut, srv.URL+"/api/profiles/test", bytes.NewBuffer([]byte(testProfile)))
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("re-read all profiles", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/profiles", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"default": {}, "test": {"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("get one specific profile (that was just added)", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/profiles/test", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.0", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("update the profile", func(t *testing.T) {
|
||||
updateProfile := `{"profile": {"kernel": {"version": "v1.0.1-newversion"}}}`
|
||||
req, err := http.NewRequest(http.MethodPatch, srv.URL+"/api/profiles/test", bytes.NewBuffer([]byte(updateProfile)))
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("get one specific profile (that was just updated)", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/profiles/test", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("test delete a profile", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodDelete, srv.URL+"/api/profiles/test", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"kernel": {"version": "v1.0.1-newversion", "args": ["kernel-args"]}}`, string(body))
|
||||
})
|
||||
}
|
||||
@@ -49,8 +49,6 @@ func DaemonInitLogging() error {
|
||||
if err == nil {
|
||||
wwlog.SetLogLevel(level)
|
||||
}
|
||||
} else {
|
||||
wwlog.SetLogLevel(wwlog.INFO)
|
||||
}
|
||||
|
||||
loginit = true
|
||||
|
||||
@@ -106,3 +106,13 @@ func GetNodeOrSetDiscoverable(hwaddr string) (node.Node, error) {
|
||||
// return the discovered node
|
||||
return db.yml.GetNode(nodeFound.Id())
|
||||
}
|
||||
|
||||
func Reload() {
|
||||
if err := LoadNodeDB(); err != nil {
|
||||
wwlog.Error("Could not load node DB: %s", err)
|
||||
}
|
||||
|
||||
if err := LoadNodeStatus(); err != nil {
|
||||
wwlog.Error("Could not prepopulate node status DB: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
90
internal/pkg/warewulfd/server/server.go
Normal file
90
internal/pkg/warewulfd/server/server.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
|
||||
"github.com/warewulf/warewulf/internal/pkg/util"
|
||||
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
|
||||
"github.com/warewulf/warewulf/internal/pkg/warewulfd/api"
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
)
|
||||
|
||||
// TODO: https://github.com/danderson/netboot/blob/master/pixiecore/dhcp.go
|
||||
// TODO: https://github.com/pin/tftp
|
||||
/*
|
||||
wrapper type for the server mux as shim requests http://efiboot//grub.efi
|
||||
which is filtered out by http to `301 Moved Permanently` what
|
||||
shim.efi can't handle. So filter out `//` before they hit go/http.
|
||||
Makes go/http more to behave like apache
|
||||
*/
|
||||
type slashFix struct {
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
/*
|
||||
Filter out the '//'
|
||||
*/
|
||||
func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = strings.Replace(r.URL.Path, "//", "/", -1)
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func defaultHandler() *slashFix {
|
||||
var wwHandler http.ServeMux
|
||||
wwHandler.HandleFunc("/provision/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/ipxe/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/efiboot/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/kernel/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/container/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/overlay-system/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/overlay-runtime/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/overlay-file/", warewulfd.OverlaySend)
|
||||
wwHandler.HandleFunc("/status", warewulfd.StatusSend)
|
||||
return &slashFix{&wwHandler}
|
||||
}
|
||||
|
||||
func RunServer() error {
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, syscall.SIGHUP)
|
||||
|
||||
go func() {
|
||||
for range c {
|
||||
wwlog.Info("Received SIGHUP, reloading...")
|
||||
warewulfd.Reload()
|
||||
}
|
||||
}()
|
||||
|
||||
warewulfd.Reload()
|
||||
|
||||
conf := warewulfconf.Get()
|
||||
daemonPort := conf.Warewulf.Port
|
||||
|
||||
auth := warewulfconf.NewAuthentication()
|
||||
if util.IsFile(conf.Paths.AuthenticationConf()) {
|
||||
if err := auth.Read(conf.Paths.AuthenticationConf()); err != nil {
|
||||
wwlog.Warn("%w\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
apiHandler := api.Handler(auth, conf.API.AllowedIPNets())
|
||||
defaultHandler := defaultHandler()
|
||||
dispatchHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api") && conf.API != nil && conf.API.Enabled() {
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
} else {
|
||||
defaultHandler.ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), dispatchHandler); err != nil {
|
||||
return fmt.Errorf("could not start listening service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package warewulfd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
)
|
||||
|
||||
// TODO: https://github.com/danderson/netboot/blob/master/pixiecore/dhcp.go
|
||||
// TODO: https://github.com/pin/tftp
|
||||
/*
|
||||
wrapper type for the server mux as shim requests http://efiboot//grub.efi
|
||||
which is filtered out by http to `301 Moved Permanently` what
|
||||
shim.efi can't handle. So filter out `//` before they hit go/http.
|
||||
Makes go/http more to behave like apache
|
||||
*/
|
||||
type slashFix struct {
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
/*
|
||||
Filter out the '//'
|
||||
*/
|
||||
func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = strings.Replace(r.URL.Path, "//", "/", -1)
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func RunServer() error {
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, syscall.SIGHUP)
|
||||
|
||||
go func() {
|
||||
for range c {
|
||||
wwlog.Warn("Received SIGHUP, reloading...")
|
||||
err := LoadNodeDB()
|
||||
if err != nil {
|
||||
wwlog.Error("Could not load node DB: %s", err)
|
||||
}
|
||||
|
||||
err = LoadNodeStatus()
|
||||
if err != nil {
|
||||
wwlog.Error("Could not prepopulate node status DB: %s", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := LoadNodeDB()
|
||||
if err != nil {
|
||||
wwlog.Error("Could not load database: %s", err)
|
||||
}
|
||||
|
||||
err = LoadNodeStatus()
|
||||
if err != nil {
|
||||
wwlog.Error("Could not prepopulate node status DB: %s", err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
wwlog.Warn("couldn't copy default shim: %s", err)
|
||||
}
|
||||
var wwHandler http.ServeMux
|
||||
wwHandler.HandleFunc("/provision/", ProvisionSend)
|
||||
wwHandler.HandleFunc("/ipxe/", ProvisionSend)
|
||||
wwHandler.HandleFunc("/efiboot/", ProvisionSend)
|
||||
wwHandler.HandleFunc("/kernel/", ProvisionSend)
|
||||
wwHandler.HandleFunc("/image/", ProvisionSend)
|
||||
wwHandler.HandleFunc("/overlay-system/", ProvisionSend)
|
||||
wwHandler.HandleFunc("/overlay-runtime/", ProvisionSend)
|
||||
wwHandler.HandleFunc("/overlay-file/", OverlaySend)
|
||||
wwHandler.HandleFunc("/status", StatusSend)
|
||||
|
||||
conf := warewulfconf.Get()
|
||||
|
||||
daemonPort := conf.Warewulf.Port
|
||||
err = http.ListenAndServe(":"+strconv.Itoa(daemonPort), &slashFix{&wwHandler})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not start listening service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
-------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
75
userdocs/server/api.rst
Normal 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
|
||||
@@ -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
|
||||
========
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user