diff --git a/CHANGELOG.md b/CHANGELOG.md index 933a547c..db2d1fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/LICENSE_DEPENDENCIES.md b/LICENSE_DEPENDENCIES.md index 39fa22e1..1452546c 100644 --- a/LICENSE_DEPENDENCIES.md +++ b/LICENSE_DEPENDENCIES.md @@ -317,6 +317,12 @@ The dependencies and their licenses are as follows: **License URL:** +## github.com/swaggest/swgui + +**License:** Apache-2.0 + +**License URL:** + ## 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:** +## gopkg.in/yaml.v2 + +**License:** Apache-2.0 + +**License URL:** + ## github.com/moby/sys/capability **License:** BSD-2-Clause @@ -467,6 +479,12 @@ The dependencies and their licenses are as follows: **License URL:** +## github.com/santhosh-tekuri/jsonschema/v3 + +**License:** BSD-3-Clause + +**License URL:** + ## github.com/spf13/pflag **License:** BSD-3-Clause @@ -629,6 +647,12 @@ The dependencies and their licenses are as follows: **License URL:** +## github.com/go-chi/chi/v5 + +**License:** MIT + +**License URL:** + ## github.com/huandu/xstrings **License:** MIT @@ -749,6 +773,42 @@ The dependencies and their licenses are as follows: **License URL:** +## github.com/swaggest/form/v5 + +**License:** MIT + +**License URL:** + +## github.com/swaggest/jsonschema-go + +**License:** MIT + +**License URL:** + +## github.com/swaggest/openapi-go + +**License:** MIT + +**License URL:** + +## github.com/swaggest/refl + +**License:** MIT + +**License URL:** + +## github.com/swaggest/rest + +**License:** MIT + +**License URL:** + +## github.com/swaggest/usecase + +**License:** MIT + +**License URL:** + ## github.com/titanous/rocacheck **License:** MIT @@ -761,6 +821,12 @@ The dependencies and their licenses are as follows: **License URL:** +## github.com/vearutop/statigz + +**License:** MIT + +**License URL:** + ## github.com/vincent-petithory/dataurl **License:** MIT diff --git a/Makefile b/Makefile index ca1ab7fc..b799a7c7 100644 --- a/Makefile +++ b/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) diff --git a/etc/auth.conf b/etc/auth.conf new file mode 100644 index 00000000..879eee8a --- /dev/null +++ b/etc/auth.conf @@ -0,0 +1,3 @@ +users: +- name: admin + password hash: "" # generate a password hash with `mkpasswd --method=bcrypt` diff --git a/go.mod b/go.mod index d6798b5a..a7f17050 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index e048c85a..7e7fc110 100644 --- a/go.sum +++ b/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= diff --git a/internal/app/wwctl/node/list/main_test.go b/internal/app/wwctl/node/list/main_test.go index 3281d771..808eb3e5 100644 --- a/internal/app/wwctl/node/list/main_test.go +++ b/internal/app/wwctl/node/list/main_test.go @@ -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 + ] } } `, diff --git a/internal/app/wwctl/profile/list/main_test.go b/internal/app/wwctl/profile/list/main_test.go index e7b57358..4d053fd8 100644 --- a/internal/app/wwctl/profile/list/main_test.go +++ b/internal/app/wwctl/profile/list/main_test.go @@ -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: ` diff --git a/internal/app/wwctl/server/root.go b/internal/app/wwctl/server/root.go index dd3f3a00..86585c12 100644 --- a/internal/app/wwctl/server/root.go +++ b/internal/app/wwctl/server/root.go @@ -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 diff --git a/internal/pkg/api/image/image.go b/internal/pkg/api/image/image.go index 674210f3..6d3cb0da 100644 --- a/internal/pkg/api/image/image.go +++ b/internal/pkg/api/image/image.go @@ -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 { diff --git a/internal/pkg/config/api.go b/internal/pkg/config/api.go new file mode 100644 index 00000000..349909c4 --- /dev/null +++ b/internal/pkg/config/api.go @@ -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) +} diff --git a/internal/pkg/config/authentication.go b/internal/pkg/config/authentication.go new file mode 100644 index 00000000..0a67cf32 --- /dev/null +++ b/internal/pkg/config/authentication.go @@ -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 + } +} diff --git a/internal/pkg/config/buildconfig.go.in b/internal/pkg/config/buildconfig.go.in index b7cd2634..6e442d34 100644 --- a/internal/pkg/config/buildconfig.go.in +++ b/internal/pkg/config/buildconfig.go.in @@ -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") } diff --git a/internal/pkg/config/root.go b/internal/pkg/config/root.go index a79a0b57..45bf788f 100644 --- a/internal/pkg/config/root.go +++ b/internal/pkg/config/root.go @@ -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) } diff --git a/internal/pkg/config/root_test.go b/internal/pkg/config/root_test.go index 3763e8cc..bad61665 100644 --- a/internal/pkg/config/root_test.go +++ b/internal/pkg/config/root_test.go @@ -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 `, }, } diff --git a/internal/pkg/image/config.go b/internal/pkg/image/config.go index 164a74f3..938d2bfc 100644 --- a/internal/pkg/image/config.go +++ b/internal/pkg/image/config.go @@ -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" +} diff --git a/internal/pkg/image/util.go b/internal/pkg/image/util.go index 3baa337f..6519a457 100644 --- a/internal/pkg/image/util.go +++ b/internal/pkg/image/util.go @@ -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 + } +} diff --git a/internal/pkg/node/constructors.go b/internal/pkg/node/constructors.go index e819ad2a..73162e91 100644 --- a/internal/pkg/node/constructors.go +++ b/internal/pkg/node/constructors.go @@ -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 diff --git a/internal/pkg/node/datastructure.go b/internal/pkg/node/datastructure.go index 9cb35639..5fa2c8f4 100644 --- a/internal/pkg/node/datastructure.go +++ b/internal/pkg/node/datastructure.go @@ -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{} diff --git a/internal/pkg/node/methods.go b/internal/pkg/node/methods.go index 881a4d12..7a9eaf1b 100644 --- a/internal/pkg/node/methods.go +++ b/internal/pkg/node/methods.go @@ -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] } diff --git a/internal/pkg/node/queries.go b/internal/pkg/node/queries.go new file mode 100644 index 00000000..1cd4ffc4 --- /dev/null +++ b/internal/pkg/node/queries.go @@ -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 +} diff --git a/internal/pkg/node/queries_test.go b/internal/pkg/node/queries_test.go new file mode 100644 index 00000000..8ff5793a --- /dev/null +++ b/internal/pkg/node/queries_test.go @@ -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)) + }) + } +} diff --git a/internal/pkg/warewulfd/api/api.go b/internal/pkg/warewulfd/api/api.go new file mode 100644 index 00000000..9e214bdc --- /dev/null +++ b/internal/pkg/warewulfd/api/api.go @@ -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 +} diff --git a/internal/pkg/warewulfd/api/authentication.go b/internal/pkg/warewulfd/api/authentication.go new file mode 100644 index 00000000..5b6711a2 --- /dev/null +++ b/internal/pkg/warewulfd/api/authentication.go @@ -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) + }) + } +} diff --git a/internal/pkg/warewulfd/api/image.go b/internal/pkg/warewulfd/api/image.go new file mode 100644 index 00000000..4156f196 --- /dev/null +++ b/internal/pkg/warewulfd/api/image.go @@ -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 +} diff --git a/internal/pkg/warewulfd/api/image_test.go b/internal/pkg/warewulfd/api/image_test.go new file mode 100644 index 00000000..eef2c7b6 --- /dev/null +++ b/internal/pkg/warewulfd/api/image_test.go @@ -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) + }) +} diff --git a/internal/pkg/warewulfd/api/node.go b/internal/pkg/warewulfd/api/node.go new file mode 100644 index 00000000..ba8f120d --- /dev/null +++ b/internal/pkg/warewulfd/api/node.go @@ -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 +} diff --git a/internal/pkg/warewulfd/api/node_test.go b/internal/pkg/warewulfd/api/node_test.go new file mode 100644 index 00000000..92c72045 --- /dev/null +++ b/internal/pkg/warewulfd/api/node_test.go @@ -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)) + }) +} diff --git a/internal/pkg/warewulfd/api/overlay.go b/internal/pkg/warewulfd/api/overlay.go new file mode 100644 index 00000000..f18f534d --- /dev/null +++ b/internal/pkg/warewulfd/api/overlay.go @@ -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 +} diff --git a/internal/pkg/warewulfd/api/overlay_test.go b/internal/pkg/warewulfd/api/overlay_test.go new file mode 100644 index 00000000..2ddf98e5 --- /dev/null +++ b/internal/pkg/warewulfd/api/overlay_test.go @@ -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)) + }) +} diff --git a/internal/pkg/warewulfd/api/profile.go b/internal/pkg/warewulfd/api/profile.go new file mode 100644 index 00000000..a62a9a03 --- /dev/null +++ b/internal/pkg/warewulfd/api/profile.go @@ -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 +} diff --git a/internal/pkg/warewulfd/api/profile_test.go b/internal/pkg/warewulfd/api/profile_test.go new file mode 100644 index 00000000..68e2754e --- /dev/null +++ b/internal/pkg/warewulfd/api/profile_test.go @@ -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)) + }) +} diff --git a/internal/pkg/warewulfd/daemon.go b/internal/pkg/warewulfd/daemon.go index b6825300..5ee29d42 100644 --- a/internal/pkg/warewulfd/daemon.go +++ b/internal/pkg/warewulfd/daemon.go @@ -49,8 +49,6 @@ func DaemonInitLogging() error { if err == nil { wwlog.SetLogLevel(level) } - } else { - wwlog.SetLogLevel(wwlog.INFO) } loginit = true diff --git a/internal/pkg/warewulfd/nodedb.go b/internal/pkg/warewulfd/nodedb.go index 4467be44..47322470 100644 --- a/internal/pkg/warewulfd/nodedb.go +++ b/internal/pkg/warewulfd/nodedb.go @@ -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) + } +} diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go new file mode 100644 index 00000000..26bac7f8 --- /dev/null +++ b/internal/pkg/warewulfd/server/server.go @@ -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 +} diff --git a/internal/pkg/warewulfd/warewulfd.go b/internal/pkg/warewulfd/warewulfd.go deleted file mode 100644 index 152611f7..00000000 --- a/internal/pkg/warewulfd/warewulfd.go +++ /dev/null @@ -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 -} diff --git a/userdocs/images/images.rst b/userdocs/images/images.rst index 918d7db4..3cdab56d 100644 --- a/userdocs/images/images.rst +++ b/userdocs/images/images.rst @@ -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 ------------------- diff --git a/userdocs/index.rst b/userdocs/index.rst index 9e15ad27..6f226bdb 100644 --- a/userdocs/index.rst +++ b/userdocs/index.rst @@ -27,6 +27,7 @@ Welcome to the Warewulf User Guide! Security Bootloaders Upgrading Warewulf + REST API .. toctree:: :maxdepth: 1 diff --git a/userdocs/overlays/overlays.rst b/userdocs/overlays/overlays.rst index 6bd40958..28222128 100644 --- a/userdocs/overlays/overlays.rst +++ b/userdocs/overlays/overlays.rst @@ -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 -------- diff --git a/userdocs/server/api.rst b/userdocs/server/api.rst new file mode 100644 index 00000000..7f884332 --- /dev/null +++ b/userdocs/server/api.rst @@ -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 diff --git a/userdocs/server/configuration.rst b/userdocs/server/configuration.rst index 43248d92..df7f6e65 100644 --- a/userdocs/server/configuration.rst +++ b/userdocs/server/configuration.rst @@ -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 ======== diff --git a/warewulf.spec.in b/warewulf.spec.in index 5cebdd3e..73a10383 100644 --- a/warewulf.spec.in +++ b/warewulf.spec.in @@ -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 +- Remove gRPC API + * Thu Feb 20 2025 Stephen Simpson - Added sos plugin