Compare commits

..

112 commits

Author SHA1 Message Date
David Allen
3253cb8bbb
Merge pull request #25 from OpenCHAMI/healthcheck
Add healthcheck for OpenCHAMI deployment recipes
2025-01-07 13:31:06 -07:00
6b0ed4c03d
server: changed endpoint from /status to /configurator/status 2025-01-07 13:27:17 -07:00
ba3690cb5d
refactor: moved /status endpoint to always be public route 2025-01-07 13:20:45 -07:00
b0b52dc432
feat: added healthcheck to server 2025-01-07 13:20:42 -07:00
David Allen
7de2dcc4e8
Merge pull request #24 from OpenCHAMI/readme-and-improvements
Update README, bug fixes, and initial `targets` API
2025-01-07 13:14:13 -07:00
5b08da019c
examples: added test template 2025-01-07 13:11:48 -07:00
3fa7b80802
readme: fixed formatting issue and added tip 2025-01-07 13:11:25 -07:00
733c486cb2
examples: updated test plugin 2025-01-07 12:57:29 -07:00
cd40fd194f
examples: fixed illegal character in dnsmasq template 2025-01-07 12:54:45 -07:00
3a3e00ce12
readme: fixed formatting issues 2025-01-07 12:53:43 -07:00
8915a28258
changed 'output' to 'dhcp_hosts' in dhcp plugin and README 2025-01-07 12:50:52 -07:00
531ad8881d
readme: added note about illegal template keys 2025-01-07 12:41:35 -07:00
a651d70dfa
generator: fixed issue where dnsmasq plugin did not output correctly 2025-01-07 12:38:27 -07:00
88d365155f
generator: fixed issue with templates not being generated 2025-01-07 12:16:08 -07:00
4f836630b0
cmd: minor changes 2025-01-07 12:14:53 -07:00
6bf75e27ce
refactor: added coredhcp default generator 2024-12-18 16:24:15 -07:00
93cf6bba12
fix: changed loadTargets to only overwrite plugin path if set 2024-12-18 16:23:47 -07:00
41aa0c7a42
plugin: updated coredhcp to match interface 2024-12-18 16:22:46 -07:00
4572418dfa
server: added error message when generate fails 2024-12-18 14:01:07 -07:00
1f87b180b9
fix: changed variable name to avoid collision with package 2024-12-18 12:47:29 -07:00
895b539c66
fix: added default generator server targets 2024-12-18 12:16:53 -07:00
2edd8367bc
fix: added check to loadTargets() to prevent panic 2024-12-18 11:33:48 -07:00
235efcdd07
fix: added function to load server targets from config 2024-12-17 16:29:02 -07:00
4ff8094988
fix: added os.Exit in commands with error 2024-12-17 16:28:13 -07:00
2b9e3d66d2
fix: changed how writeErrorResponse works 2024-12-16 16:03:41 -07:00
5c9e9f0540
cmd: changed 'server' variable name to not collide with package name 2024-12-16 15:40:20 -07:00
34bbd1ce85
server: fixed error message handling 2024-12-16 15:24:52 -07:00
cccf6321cc
fix: set clientopts correctly in generate.go 2024-12-13 12:23:17 -07:00
9951b9a1f3
refactor: added more logging info 2024-12-12 14:29:41 -07:00
e1ab1e7102
refactor: added client opts to serve.cmd and more logging info 2024-12-12 14:28:48 -07:00
ebe4e02cf0
fix: minor changes 2024-12-11 10:53:10 -07:00
eda3cce177
refactor: changed log.Printf to log.Error in config 2024-12-11 08:58:05 -07:00
10ed21c5c1
fix: added yaml tag to prevent marshaler from crashing 2024-12-11 08:57:24 -07:00
b6c3533327
chore: tidy and code cleanup 2024-12-10 19:10:07 -07:00
66bb1e6c1a
fix: changed to use correct logging library 2024-12-10 19:09:49 -07:00
d02a49fe80
refactor: changed fmt.Print to log.Info in cmd/fetch 2024-12-10 18:35:55 -07:00
ad45a540f0
chore: tidy and code cleanup 2024-12-10 18:31:03 -07:00
d2b6178350
tests: fixed issue with server not starting with correct config 2024-12-10 15:22:37 -07:00
1848819244
config: updated to use only host and not port var 2024-12-10 15:22:03 -07:00
0fc81ac67c
tests: updated tests to use local packages 2024-12-10 15:09:43 -07:00
c8aa4aae93
generator: added check for *.so extension 2024-12-10 15:09:13 -07:00
9cef01acf3
go.mod: updated deps 2024-12-10 15:08:38 -07:00
7cb4404cbb
tests: fixed minor issues 2024-12-04 16:06:16 -07:00
221bb9a23a
tests: changed how the test generator is added 2024-12-04 15:59:05 -07:00
ebd5c46092
fix: corrected log message 2024-12-04 15:52:22 -07:00
0569c33633
refactor: converted more fmt.Printf to log.* 2024-12-04 15:48:45 -07:00
31d14bcc53
tests: updated to use new API 2024-12-04 15:45:07 -07:00
678e6b66bd
fix: changed logging import to zerolog 2024-12-04 15:44:48 -07:00
e3a8461828
cmd: updated --host flag and removed --port 2024-12-03 15:54:53 -07:00
8f23422db0
refactor: minor changes 2024-12-03 13:45:54 -07:00
c5ee0552b4
readme: updated example to use coredhcp 2024-12-03 13:37:58 -07:00
b858ff3fe5
readme: updated example to use coredhcp and other changes 2024-12-03 13:30:03 -07:00
40c8564681
fix: changed more output to use log instead of fmt 2024-12-03 12:52:06 -07:00
e1a9f4ae36
fix: changed output to use log instead of fmt 2024-12-03 12:51:48 -07:00
5ca4d17d42
fix: added missing output maps 2024-12-03 12:51:13 -07:00
69aac3c929
refactor: more code cleanup and reorganization 2024-11-21 14:16:03 -07:00
72dd8416aa
feat: add initial implementation of server target API 2024-11-21 14:15:43 -07:00
a7b8fb0de5
refactor: more code cleanup and simplification 2024-11-21 14:14:44 -07:00
32065dc163
refactor: name changes and code clean up 2024-11-21 14:13:31 -07:00
b31056f297
readme: update information 2024-11-21 14:10:27 -07:00
David Allen
5b351d2cac
Merge pull request #22 from OpenCHAMI/fix-minor-issues
Fix minor issues
2024-11-14 17:25:38 -07:00
f0c48d5d77
gitignore: added dist/ to file 2024-11-14 17:07:26 -07:00
516f100075
chore: moved contents of dist/ to res/ 2024-11-14 17:06:49 -07:00
dac6c2306f
client: moved cacert logic from 'serve' cmd to client 2024-11-14 16:53:00 -07:00
043f8ec120
goreleaser/docker: removed lib/ refs and other minor changes 2024-11-14 16:30:26 -07:00
b5d492d6c0
cmd: added error when specifying config path but not found 2024-11-14 16:26:17 -07:00
4bd4dac129
cmd: removed flag and added check for cacert 2024-11-14 16:21:04 -07:00
2a9e7c72dc
generator: added warn when default generator not found and fix error messages 2024-11-14 13:45:19 -07:00
e93bef79f2
cmd: moved --cacert flag to use with 'serve' command 2024-11-14 13:44:24 -07:00
ca6e4a8625
goreleaser: fix typo with builds.flags 2024-11-14 13:43:28 -07:00
Alex Lovell-Troy
a55ecf2b88
Merge pull request #21 from OpenCHAMI/20-convert-plugins
Build default plugins directly into the binary executable
2024-11-14 15:26:15 -05:00
9f6a8ac428
refactor: added default plugins and check before loading 2024-11-13 17:42:48 -07:00
0bbd22a558
goreleaser: updated build command 2024-11-13 17:41:42 -07:00
34845f6a5c
plugin: moved default plugins to compile into executable 2024-11-13 17:40:12 -07:00
Alex Lovell-Troy
9328d2a7a1
Merge pull request #13 from OpenCHAMI/pkgbuild
Add PKGBUILD to install configurator binary with plugins
2024-10-24 11:27:43 -04:00
ed0b8f8b59
workflow: added docker login to push package 2024-10-02 15:26:41 -06:00
34acf3d95b
goreleaser: add hook to build plugins 2024-10-02 14:55:18 -06:00
113b6a9368
goreleaser: added lib/ to archive and dockers 2024-10-02 14:49:35 -06:00
David Allen
aaeac5132b
Merge pull request #17 from OpenCHAMI/fix-goreleaser
Fix .goreleaser to build release and package correctly
2024-10-02 14:41:33 -06:00
8afcf6f005
Added dockers to build image and removed unnecessary file 2024-10-02 14:31:01 -06:00
8a1fa5211b
Added CHANGELOG.md 2024-10-02 13:58:31 -06:00
35ee21b3f7
Added consoles substitution to conman plugin 2024-10-02 13:46:51 -06:00
397bfa5b31
Fixed typo in conman plugin 2024-10-02 13:29:56 -06:00
99eb87d806
Renamed LICENSE.md to LICENSE 2024-10-02 11:50:28 -06:00
David Allen
9f0e48eb7f
Merge pull request #15 from OpenCHAMI/compress
Add option to compress/archive multiple generated files
2024-10-02 11:04:09 -06:00
David Allen
6cc8a873bf
Merge pull request #14 from OpenCHAMI/minor-refactor
Minor refactoring and improvements
2024-10-02 11:03:24 -06:00
David Allen
0d4b8e92fc
Merge pull request #11 from OpenCHAMI/docker
Add Dockerfile, Makefile rule, Goreleaser, and Github action
2024-09-25 18:17:49 -06:00
David Allen
8099ca9d0f
Merge branch 'main' into docker
Signed-off-by: David Allen <16520934+davidallendj@users.noreply.github.com>
2024-09-25 18:17:34 -06:00
e14a8565df
Added --always flag to git_tag to prevent erroring out 2024-09-25 18:09:25 -06:00
84e27d622a
Added option to compress and archive multiple generated files 2024-09-24 15:27:23 -06:00
bc6e856179
Removed plugins rule that have binaries rule 2024-09-23 11:29:38 -06:00
e05bd58ef6
Changed binaries rule to include build plugins 2024-09-23 11:28:53 -06:00
cb73258a84
Minor changes to error format in dhcpd plugin 2024-09-20 16:56:46 -06:00
751a2facdb
Minor changes to util functions 2024-09-20 16:56:15 -06:00
601089672c
Changed how plugins and tempates are loaded 2024-09-20 16:55:31 -06:00
b922dbdbda
Removed VerifyClaims 2024-09-20 16:54:25 -06:00
e044d4b5ed
Changed from using multiple plugin paths to just one 2024-09-20 16:53:30 -06:00
b488c32195
Updated vars in 'root' cmd 2024-09-20 16:52:31 -06:00
41c0e24c06
Changed logic for RunTargets in 'generate' cmd 2024-09-20 16:52:00 -06:00
6785402928
Removed vars from fetch cmd 2024-09-20 16:51:18 -06:00
c822531fde
Added check to remove duplicates in 'inspect' cmd 2024-09-20 16:50:45 -06:00
cd57c36d7e
Added PKGBUILD to install configurator binary with plugins 2024-09-18 21:27:10 -06:00
David Allen
be9db173a3
Made building pluging and executable prereqs for test rule 2024-08-01 16:40:28 -06:00
David Allen
dcff41dd43
Add shell directive to git_tag 2024-08-01 16:14:47 -06:00
David Allen
49fd6fb892
Changed space indentations to tabs in Makefile 2024-08-01 16:10:35 -06:00
David Allen
7a1b57931e
Updated Makefile with changes to container rules 2024-08-01 16:02:58 -06:00
David Allen
73ca17dce6
Updated Makefile with recommended changes 2024-08-01 15:47:24 -06:00
David Allen
80ade5bf6f
Updated README.md to include Docker section 2024-08-01 11:02:42 -06:00
David Allen
a0ee615d30
Added local Docker container rule for testing locally 2024-07-30 11:32:21 -06:00
David Allen
dbea108f74
Updated Dockerfile and Makefile 2024-07-30 11:29:19 -06:00
David Allen
6f027fa7fb
Added goreleaser and GitHub workflow 2024-07-30 10:47:39 -06:00
David Allen
699ff76e42
Added Dockerfile and Makefile rule 2024-07-30 10:36:16 -06:00
43 changed files with 1634 additions and 1077 deletions

45
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,45 @@
name: Release with goreleaser
on:
workflow_dispatch:
push:
tags:
- v*
permissions: write-all # Necessary for the generate-build-provenance action with containers
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.21
uses: actions/setup-go@v5
with:
go-version: 1.21
- name: Docker Login
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v4
with:
fetch-tags: 1
fetch-depth: 0
- name: Release with goreleaser
uses: goreleaser/goreleaser-action@v6
env:
GITHUB_TOKEN: ${{ github.token }}
with:
version: latest
args: release --clean
id: goreleaser
- name: Attest Binaries
uses: actions/attest-build-provenance@v1
with:
subject-path: '${{ github.workspace }}/dist/configurator_linux_amd64_v1/configurator'

1
.gitignore vendored
View file

@ -5,3 +5,4 @@
**.conf
**.ignore
**.tar.gz
dist/

61
.goreleaser.yaml Normal file
View file

@ -0,0 +1,61 @@
version: 2
before:
hooks:
- go mod download
- make plugins
builds:
- id: "configurator"
goos:
- linux
goarch:
- amd64
- arm64
flags:
- -tags=all
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of uname.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
files:
- LICENSE
- CHANGELOG.md
- README.md
dockers:
-
image_templates:
- ghcr.io/openchami/{{.ProjectName}}:latest
- ghcr.io/openchami/{{.ProjectName}}:{{ .Tag }}
- ghcr.io/openchami/{{.ProjectName}}:v{{ .Major }}
- ghcr.io/openchami/{{.ProjectName}}:v{{ .Major }}.{{ .Minor }}
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
extra_files:
- LICENSE
- CHANGELOG.md
- README.md
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
release:
github:
name_template: "{{.Version}}"
prerelease: auto
mode: append

10
CHANGELOG.md Normal file
View file

@ -0,0 +1,10 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
[0.1.0]
- Initial prerelease of configurator

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM cgr.dev/chainguard/wolfi-base
RUN apk add --no-cache tini bash
RUN mkdir -p /configurator
# nobody 65534:65534
USER 65534:65534
# copy the binary and all of the default plugins
COPY configurator /configurator/configurator
CMD ["/configurator/configurator"]
ENTRYPOINT [ "/sbin/tini", "--" ]

View file

@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,35 +1,56 @@
# Unless set otherwise, the container runtime is Docker
DOCKER ?= docker
prog ?= configurator
git_tag := $(shell git describe --abbrev=0 --tags --always)
sources := main.go $(wildcard cmd/*.go)
plugin_source_prefix := pkg/generator/plugins
plugin_sources := $(filter-out %_test.go,$(wildcard $(plugin_source_prefix)/*/*.go))
plugin_binaries := $(addprefix lib/,$(patsubst %.go,%.so,$(notdir $(plugin_sources))))
# build everything at once
.PHONY: all
all: plugins exe test
# build the main executable to make configs
.PHONY: main driver binaries exe
main: exe
driver: exe
exe:
go build --tags=all -o configurator
binaries: exe plugins
exe: $(prog)
# build named executable from go sources
$(prog): $(sources)
go build --tags=all -o $(prog)
.PHONY: container
container: binaries
$(DOCKER) build . --build-arg --no-cache --pull --tag '$(prog):$(git_tag)-dirty'
.PHONY: container-testing
container-testing: binaries
$(DOCKER) build . --tag $(prog):testing
# build all of the generators into plugins
plugins:
.PHONY: plugins
plugins: $(plugin_binaries)
# how to make each plugin
lib/%.so: pkg/generator/plugins/%/*.go
mkdir -p lib
go build -buildmode=plugin -o lib/conman.so pkg/generator/plugins/conman/conman.go
go build -buildmode=plugin -o lib/coredhcp.so pkg/generator/plugins/coredhcp/coredhcp.go
go build -buildmode=plugin -o lib/dhcpd.so pkg/generator/plugins/dhcpd/dhcpd.go
go build -buildmode=plugin -o lib/dnsmasq.so pkg/generator/plugins/dnsmasq/dnsmasq.go
go build -buildmode=plugin -o lib/example.so pkg/generator/plugins/example/example.go
go build -buildmode=plugin -o lib/hostfile.so pkg/generator/plugins/hostfile/hostfile.go
go build -buildmode=plugin -o lib/powerman.so pkg/generator/plugins/powerman/powerman.go
go build -buildmode=plugin -o lib/syslog.so pkg/generator/plugins/syslog/syslog.go
go build -buildmode=plugin -o lib/warewulf.so pkg/generator/plugins/warewulf/warewulf.go
go build -buildmode=plugin -o $@ $<
docs:
go doc github.com/OpenCHAMI/cmd
go doc github.com/OpenCHAMI/pkg/configurator
# remove executable and all built plugins
.PHONY: clean
clean:
rm configurator
rm lib/*
rm -f configurator
rm -f lib/*
# run all of the unit tests
test:
go test ./tests/generate_test.go --tags=all
.PHONY: test
test: $(prog) $(plugin_binaries)
go test ./tests/generate_test.go --tags=all

159
README.md
View file

@ -1,91 +1,138 @@
# OpenCHAMI Configurator
The `configurator` (portmanteau of config + generator) is an extensible tool that fetchs data from an instance of [SMD](https://github.com/OpenCHAMI/smd) to generate commonly used config files based on Jinja 2 template files. The tool and generator plugins are written in Go and plugins can be written by following the ["Creating Generator Plugins"](#creating-generator-plugins) section of this README.
The `configurator` is an extensible tool that is capable of dynamically generating files on the fly. The tool includes a built-in generator that fetchs data from an instance of [SMD](https://github.com/OpenCHAMI/smd) to generate files based on Jinja 2 template files. The tool and generator plugins are written in Go and plugins can be written by following the ["Creating Generator Plugins"](#creating-generator-plugins) section of this README.
## Building and Usage
The `configurator` is built using standard `go` build tools. The project separates the client and server with build tags. To get started, clone the project, download the dependencies, and build the project:
The `configurator` is built using standard `go` build tools. The project separates the client, server, and generator components using build tags. To get started, clone the project, download the dependencies, and build the project:
```bash
git clone https://github.com/OpenCHAMI/configurator.git
go mod tidy
go build --tags all # equivalent to `go build --tags client,server``
## ...or just run `make` in project directory
```
This will build the main driver program, but also requires generator plugins to define how new config files are generated. The default plugins can be built using the following build command:
This will build the main driver program with the default generators that are found in the `pkg/generators` directory.
> [!WARNING]
> Not all of the plugins have completed generation implementations and are a WIP.
### Running Configurator with CLI
After you build the program, run the following command to use the tool:
```bash
go build -buildmode=plugin -o lib/conman.so internal/generator/plugins/conman/conman.go
go build -buildmode=plugin -o lib/coredhcp.so internal/generator/plugins/coredhcp/coredhcp.go
go build -buildmode=plugin -o lib/dnsmasq.so internal/generator/plugins/dnsmasq/dnsmasq.go
go build -buildmode=plugin -o lib/powerman.so internal/generator/plugins/powerman/powerman.go
go build -buildmode=plugin -o lib/syslog.so internal/generator/plugins/syslog/syslog.go
export ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIs...
./configurator generate --config config.yaml --target coredhcp -o coredhcp.conf --cacert ochami.pem
```
**NOTE: Not all of the plugins have completed generation implementations and are WIP.**
This will generate a new `coredhcp` config file based on the Jinja 2 template specified in the config file for "coredhcp". The files will be written to `coredhcp.conf` as specified with the `-o/--output` flag. The `--target` flag specifies the type of config file to generate by its name (see the [`Creating Generator Plugins`](#creating-generator-plugins) section for details).
These commands will build the default plugins and store them in the "lib" directory by default. Alternatively, the plugins can be built using `make plugins` if GNU make is installed and available. After you build the plugins, run the following to use the tool:
In other words, there should be an entry in the config file that looks like this:
```yaml
...
targets:
coredhcp:
plugin: "lib/coredhcp.so" # optional, if we want to use an external plugin instead
templates:
- templates/coredhcp.j2
...
```bash
./configurator generate --config config.yaml --target dnsmasq -o dnsmasq.conf
```
This will generate a new `dnsmasq` config file based on the Jinja 2 template specified in the config file for "dnsmasq". The `--target` flag specifies the type of config file to generate by its name (see the [`Creating Generator Plugins`](#creating-generator-plugins) section for details). The `configurator` tool requires a valid access token when making requests to an instance of SMD that has protected routes.
> [!NOTE]
> The `configurator` tool requires a valid access token when making requests to an instance of SMD that has protected routes.
### Running Configurator as a Service
The tool can also run as a service to generate files for clients:
```bash
export CONFIGURATOR_JWKS_URL="http://my.openchami.cluster:8443/key"
./configurator serve --config config.yaml
```
Once the server is up and listening for HTTP requests, you can try making a request to it with `curl` or `configurator fetch`. Both commands below are essentially equivalent:
```bash
curl http://127.0.0.1:3334/generate?target=dnsmasq -H "Authorization: Bearer $ACCESS_TOKEN"
export ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIs...
curl http://127.0.0.1:3334/generate?target=coredhcp -X GET -H "Authorization: Bearer $ACCESS_TOKEN" --cacert ochami.pem
# ...or...
./configurator fetch --target dnsmasq --host http://127.0.0.1 --port 3334
./configurator fetch --target coredhcp --host http://127.0.0.1:3334 --cacert ochami.pem
```
This will do the same thing as the `generate` subcommand, but remotely. The access token is only required if the `CONFIGURATOR_JWKS_URL` environment variable is set. The `ACCESS_TOKEN` environment variable passed to `curl` and it's corresponding CLI argument both expects a token as a JWT.
This will do the same thing as the `generate` subcommand, but through a GET request where the file contents is returned in the response. The access token is only required if the `CONFIGURATOR_JWKS_URL` environment variable is set when starting the server with `serve`. The `ACCESS_TOKEN` environment variable is passed to `curl` using the `Authorization` header and expects a token as a JWT.
### Docker
New images can be built and tested using the `Dockerfile` provided in the project. However, the binary executable and the generator plugins must first be built before building the image since the Docker build copies the binary over. Therefore, build all of the binaries first by following the first section of ["Building and Usage"](#building-and-usage). Running `make docker` from the Makefile will automate this process. Otherwise, run the `docker build` command after building the executable and libraries.
```bash
docker build -t configurator:testing path/to/configurator/Dockerfile
```
If you want to easily include your own external generator plugins, you can build it and copy the `lib.so` file to `lib/`. Make sure that the `Generator` interface is implemented correctly as described in the ["Creating Generator Plugins"](#creating-generator-plugins) or the plugin will not load (you should get an error that specifically says this). Additionally, the name string returned from the `GetName()` method is used for looking up the plugin with the `--target` flag by the main driver program.
Alternatively, pull the latest existing image/container from the GitHub container repository.
```bash
docker pull ghcr.io/openchami/configurator:latest
```
Then, run the Docker container similarly to running the binary.
```bash
export ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIs...
docker run ghcr.io/openchami/configurator:latest configurator generate --config config.yaml --target coredhcp -o coredhcp.conf --cacert configurator.pem
```
### Creating Generator Plugins
The `configurator` uses generator plugins to define how config files are generated using a `Generator` interface. The interface is defined like so:
The `configurator` uses built-in and user-defined generators that implement the `Generator` interface to describe how config files should be generated. The interface is defined like so:
```go
type Files = map[string][]byte
// maps the file path to its contents
type FileMap = map[string][]byte
// interface for generator plugins
type Generator interface {
GetName() string
GetVersion() string
GetDescription() string
Generate(config *configurator.Config, opts ...util.Option) (Files, error)
Generate(config *configurator.Config, opts ...util.Option) (FileMap, error)
}
```
A new plugin can be created by implementing the methods from interface and exporting a symbol with `Generator` as the name and the plugin struct as the type. The `GetName()` function returns the name that is used for looking up the corresponding template set in your config file. It can also be included in the templated files with the default plugins using the `{{ plugin_name }}` in your template. The `GetVersion()` and `GetDescription()` functions returns the version and description of the plugin which can be included in the templated files using `{{ plugin_version }}` and `{{ plugin_description }}` respectively with the default plugins. The `Generate` function is where the magic happens to build the config file from a template.
A new plugin can be created by implementing the methods from interface and exporting a symbol with `Generator` as the name and the plugin struct as the type. The `GetName()` function returns the name that is used for looking up the corresponding target set in your config file. It can also be included in the templated files with the default plugins using the `{{ plugin_name }}` in your template. The `GetVersion()` and `GetDescription()` functions returns the version and description of the plugin which can be included in the templated files using `{{ plugin_version }}` and `{{ plugin_description }}` respectively with the default plugins. The `Generate` function is where the magic happens to build the config file from a template.
```go
package main
type MyGenerator struct {}
type MyGenerator struct {
PluginInfo map[string]any
}
var pluginInfo map[string]any
// this function is not a part of the `Generator` interface
func (g *MyGenerator) LoadFromFile() map[string]any{ /*...*/ }
func (g *MyGenerator) GetName() string {
// just an example...this can be done however you want
pluginInfo := LoadFromFile("path/to/plugin/info.json")
return pluginInfo["name"]
g.PluginInfo := LoadFromFile("path/to/plugin/info.json")
return g.PluginInfo["name"]
}
func (g *MyGenerator) GetVersion() string {
return "v1.0.0"
return g.PluginInfo["version"] // "v1.0.0"
}
func (g *MyGenerator) GetDescription() string {
return "This is an example plugin."
return g.PluginInfo["description"] // "This is an example plugin."
}
func (g *MyGenerator) Generate(config *configurator.Config, opts ...util.Option) (generator.Files, error) {
func (g *MyGenerator) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
// do config generation stuff here...
var (
params = generator.GetParams(opts...)
@ -95,28 +142,35 @@ func (g *MyGenerator) Generate(config *configurator.Config, opts ...util.Option)
if client {
eths, err := client.FetchEthernetInterfaces(opts...)
// ... blah, blah, blah, check error, format output, and so on...
}
// apply the substitutions to Jinja template and return output as byte array
return generator.ApplyTemplate(path, generator.Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
"output": output,
})
// apply the substitutions to Jinja template and return output as FileMap (i.e. path and it's contents)
return generator.ApplyTemplate(path, generator.Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
"dhcp_hosts": output,
})
}
}
// this MUST be named "Generator" for symbol lookup in main driver
var Generator MyGenerator
```
> [!NOTE]
> The keys in `generator.ApplyTemplate` must not contain illegal characters such as a `-` or else the templates will not apply correctly.
Finally, build the plugin and put it somewhere specified by `plugins` in your config. Make sure that the package is `main` before building.
```bash
go build -buildmode=plugin -o lib/mygenerator.so path/to/mygenerator.go
```
Now your plugin should be available to use with the `configurator` main driver. If you get an error about not loading the correct symbol type, make sure that you generator function definitions match the `Generator` interface exactly.
Now your plugin should be available to use with the `configurator` main driver program. If you get an error about not loading the correct symbol type, make sure that your generator function definitions match the `Generator` interface entirely and that you don't have a partially implemented interface.
> [!TIP]
> See the `examples/test.go` file for a plugin and template example.
## Configuration
@ -126,46 +180,33 @@ Here is an example config file to start using configurator:
server: # Server-related parameters when using as service
host: 127.0.0.1
port: 3334
jwks: # Set the JWKS uri to protect /generate route
jwks: # Set the JWKS uri for protected routes
uri: ""
retries: 5
smd: . # SMD-related parameters
host: http://127.0.0.1
port: 27779
smd: # SMD-related parameters
host: http://127.0.0.1:27779
plugins: # path to plugin directories
- "lib/"
targets: # targets to call with --target flag
dnsmasq:
coredhcp:
templates:
- templates/dnsmasq.jinja
warewulf:
templates: # files using Jinja templating
- templates/warewulf/vnfs/dhcpd-template.jinja
- templates/warewulf/vnfs/dnsmasq-template.jinja
- templates/coredhcp.j2
files: # files to be copied without templating
- templates/warewulf/defaults/provision.jinja
- templates/warewulf/defaults/node.jinja
- templates/warewulf/filesystem/examples/*
- templates/warewulf/vnfs/*
- templates/warewulf/bootstrap.jinja
- templates/warewulf/database.jinja
targets: # additional targets to run
- extra/nodes.conf
targets: # additional targets to run (does not run recursively)
- dnsmasq
```
The `server` section sets the properties for running the `configurator` tool as a service and is not required if you're only using the CLI. Also note that the `jwks-uri` parameter is only needs for protecting endpoints. If it is not set, then the API is entirely public. The `smd` section tells the `configurator` tool where to find SMD to pull state management data used by the internal client. The `templates` section is where the paths are mapped to each generator plugin by its name (see the [`Creating Generator Plugins`](#creating-generator-plugins) section for details). The `plugins` is a list of paths to load generator plugins.
The `server` section sets the properties for running the `configurator` tool as a service and is not required if you're only using the CLI. Also note that the `jwks.uri` parameter is only needed for protecting endpoints. If it is not set, then all API routes are entirely public. The `smd` section tells the `configurator` tool where to find the SMD service to pull state management data used internally by the client's generator. The `templates` section is where the paths are mapped to each generator by its name (see the [`Creating Generator Plugins`](#creating-generator-plugins) section for details). The `plugins` is a list of paths to search for and load external generator plugins.
## Running the Tests
The `configurator` project includes a collection of tests focused on verifying plugin behavior and generating files. The tests do not currently test fetching information from SMD (or whatever remote source). The tests can be ran with either of the following commands:
The `configurator` project includes a collection of tests focused on verifying plugin behavior and generating files. The tests do not include fetching information from any remote sources, can be ran with the following command:
```bash
go test ./tests/generate_test.go --tags=all
# ...or alternatively with GNU make...
make test
```
## Known Issues
- Adds a new `OAuthClient` with every token request

View file

@ -1,11 +1,10 @@
package cmd
import (
"fmt"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
@ -20,10 +19,10 @@ var configCmd = &cobra.Command{
for _, path := range args {
// check and make sure something doesn't exist first
if exists, err := util.PathExists(path); exists || err != nil {
fmt.Printf("file or directory exists\n")
log.Error().Err(err).Msg("file or directory exists")
continue
}
configurator.SaveDefaultConfig(path)
config.SaveDefault(path)
}
},
}

View file

@ -13,12 +13,6 @@ import (
"github.com/spf13/cobra"
)
var (
accessToken string
remoteHost string
remotePort int
)
var fetchCmd = &cobra.Command{
Use: "fetch",
Short: "Fetch a config file from a remote instance of configurator",
@ -30,16 +24,22 @@ var fetchCmd = &cobra.Command{
return
}
// check if we actually have any targets to run
if len(targets) <= 0 {
log.Error().Msg("must specify a target")
os.Exit(1)
}
// check to see if an access token is available from env
if config.AccessToken == "" {
if conf.AccessToken == "" {
// check if OCHAMI_ACCESS_TOKEN env var is set if no access token is provided and use that instead
accessToken := os.Getenv("ACCESS_TOKEN")
if accessToken != "" {
config.AccessToken = accessToken
conf.AccessToken = accessToken
} else {
// TODO: try and fetch token first if it is needed
if verbose {
fmt.Printf("No token found. Attempting to generate config without one...\n")
log.Warn().Msg("No token found. Attempting to generate config without one...")
}
}
}
@ -52,25 +52,23 @@ var fetchCmd = &cobra.Command{
for _, target := range targets {
// make a request for each target
url := fmt.Sprintf("%s:%d/generate?target=%s", remoteHost, remotePort, target)
url := fmt.Sprintf("%s/generate?target=%s", remoteHost, target)
res, body, err := util.MakeRequest(url, http.MethodGet, nil, headers)
if err != nil {
log.Error().Err(err).Msg("failed to make request")
log.Error().Err(err).Msg("failed to fetch files")
return
}
// handle getting other error codes other than a 200
if res != nil {
if res.StatusCode == http.StatusOK {
log.Info().Msgf("%s\n", string(body))
}
// NOTE: the server responses are already marshaled to JSON
fmt.Print(string(body))
}
}
},
}
func init() {
fetchCmd.Flags().StringVar(&remoteHost, "host", "", "set the remote configurator host")
fetchCmd.Flags().IntVar(&remotePort, "port", 3334, "set the remote configurator port")
fetchCmd.Flags().StringVar(&remoteHost, "host", "", "set the remote configurator host and port")
fetchCmd.Flags().StringSliceVar(&targets, "target", nil, "set the target configs to make")
fetchCmd.Flags().StringVarP(&outputPath, "output", "o", "", "set the output path for config targets")
fetchCmd.Flags().StringVar(&accessToken, "access-token", "o", "set the output path for config targets")

View file

@ -9,16 +9,19 @@ import (
"os"
"path/filepath"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/client"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/util"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var (
tokenFetchRetries int
pluginPaths []string
cacertPath string
templatePaths []string
pluginPath string
useCompression bool
)
var generateCmd = &cobra.Command{
@ -26,43 +29,76 @@ var generateCmd = &cobra.Command{
Short: "Generate a config file from state management",
Run: func(cmd *cobra.Command, args []string) {
// make sure that we have a token present before trying to make request
if config.AccessToken == "" {
// TODO: make request to check if request will need token
if conf.AccessToken == "" {
// check if OCHAMI_ACCESS_TOKEN env var is set if no access token is provided and use that instead
accessToken := os.Getenv("ACCESS_TOKEN")
if accessToken != "" {
config.AccessToken = accessToken
conf.AccessToken = accessToken
} else {
// TODO: try and fetch token first if it is needed
if verbose {
fmt.Printf("No token found. Attempting to generate config without one...\n")
log.Warn().Msg("No token found. Attempting to generate conf without one...\n")
}
}
}
// use cert path from cobra if empty
// TODO: this needs to be checked for the correct desired behavior
if config.CertPath == "" {
config.CertPath = cacertPath
if conf.CertPath == "" {
conf.CertPath = cacertPath
}
// use config plugins if none supplied via CLI
if len(pluginPaths) <= 0 {
pluginPaths = append(pluginPaths, config.PluginDirs...)
}
// show config as JSON and generators if verbose
// show conf as JSON and generators if verbose
if verbose {
b, err := json.MarshalIndent(config, "", " ")
b, err := json.MarshalIndent(conf, "", " ")
if err != nil {
fmt.Printf("failed to marshal config: %v\n", err)
log.Error().Err(err).Msg("failed to marshal config")
}
// print the config file as JSON
fmt.Printf("%v\n", string(b))
}
RunTargets(&config, args, targets...)
// run all of the target recursively until completion if provided
if len(targets) > 0 {
RunTargets(&conf, args, targets...)
} else {
if pluginPath == "" {
log.Error().Msg("no plugin path specified")
return
}
// load the templates to use
templates := map[string]generator.Template{}
for _, path := range templatePaths {
template := generator.Template{}
template.LoadFromFile(path)
if !template.IsEmpty() {
templates[path] = template
}
}
params := generator.Params{
Templates: templates,
}
// set the client options
// params.ClientOpts = append(params.ClientOpts, client.WithHost(remoteHost))
if conf.AccessToken != "" {
params.ClientOpts = append(params.ClientOpts, client.WithAccessToken(conf.AccessToken))
}
if conf.CertPath != "" {
params.ClientOpts = append(params.ClientOpts, client.WithCertPoolFile(conf.CertPath))
}
// run generator.Generate() with just plugin path and templates provided
outputBytes, err := generator.Generate(&conf, pluginPath, params)
if err != nil {
log.Error().Err(err).Msg("failed to generate files")
}
// if we have more than one target and output is set, create configs in directory
outputMap := generator.ConvertContentsToString(outputBytes)
writeOutput(outputBytes, len(targets), len(outputMap))
}
},
}
@ -72,82 +108,105 @@ var generateCmd = &cobra.Command{
// child targets if it is the same as the parent.
//
// NOTE: This may be changed in the future how this is done.
func RunTargets(config *configurator.Config, args []string, targets ...string) {
func RunTargets(conf *config.Config, args []string, targets ...string) {
// generate config with each supplied target
for _, target := range targets {
params := generator.Params{
Args: args,
PluginPaths: pluginPaths,
Target: target,
Verbose: verbose,
}
outputBytes, err := generator.GenerateWithTarget(config, params)
outputBytes, err := generator.GenerateWithTarget(conf, target)
if err != nil {
fmt.Printf("failed to generate config: %v\n", err)
log.Error().Err(err).Str("target", target).Msg("failed to generate config")
os.Exit(1)
}
outputMap := generator.ConvertContentsToString(outputBytes)
// if we have more than one target and output is set, create configs in directory
var (
targetCount = len(targets)
templateCount = len(outputMap)
)
if outputPath == "" {
// write only to stdout by default
if len(outputMap) == 1 {
for _, contents := range outputMap {
fmt.Printf("%s\n", string(contents))
}
} else {
for path, contents := range outputMap {
fmt.Printf("-- file: %s, size: %d B\n%s\n", path, len(contents), string(contents))
}
}
} else if outputPath != "" && targetCount == 1 && templateCount == 1 {
// write just a single file using provided name
for _, contents := range outputBytes {
err := os.WriteFile(outputPath, contents, 0o644)
if err != nil {
fmt.Printf("failed to write config to file: %v", err)
os.Exit(1)
}
fmt.Printf("wrote file to '%s'\n", outputPath)
}
} else if outputPath != "" && targetCount > 1 || templateCount > 1 {
// write multiple files in directory using template name
err := os.MkdirAll(filepath.Clean(outputPath), 0o755)
if err != nil {
fmt.Printf("failed to make output directory: %v\n", err)
os.Exit(1)
}
for path, contents := range outputBytes {
filename := filepath.Base(path)
cleanPath := fmt.Sprintf("%s/%s", filepath.Clean(outputPath), filename)
err := os.WriteFile(cleanPath, contents, 0o755)
if err != nil {
fmt.Printf("failed to write config to file: %v\n", err)
os.Exit(1)
}
fmt.Printf("wrote file to '%s'\n", cleanPath)
}
}
outputMap := generator.ConvertContentsToString(outputBytes)
writeOutput(outputBytes, len(targets), len(outputMap))
// remove any targets that are the same as current to prevent infinite loop
nextTargets := util.CopyIf(config.Targets[target].RunTargets, func(t string) bool { return t != target })
nextTargets := util.CopyIf(conf.Targets[target].RunTargets, func(nextTarget string) bool {
return nextTarget != target
})
// ...then, run any other targets that the current target has
RunTargets(config, args, nextTargets...)
RunTargets(conf, args, nextTargets...)
}
}
func writeOutput(outputBytes generator.FileMap, targetCount int, templateCount int) {
outputMap := generator.ConvertContentsToString(outputBytes)
if outputPath == "" {
// write only to stdout by default
if len(outputMap) == 1 {
for _, contents := range outputMap {
fmt.Printf("%s\n", string(contents))
}
} else {
for path, contents := range outputMap {
fmt.Printf("-- file: %s, size: %d B\n%s\n", path, len(contents), string(contents))
}
}
} else if outputPath != "" && targetCount == 1 && templateCount == 1 {
// write just a single file using provided name
for _, contents := range outputBytes {
err := os.WriteFile(outputPath, contents, 0o644)
if err != nil {
log.Error().Err(err).Str("path", outputPath).Msg("failed to write config file")
os.Exit(1)
}
log.Info().Msgf("wrote file to '%s'\n", outputPath)
}
} else if outputPath != "" && targetCount > 1 && useCompression {
// write multiple files to archive, compress, then save to output path
out, err := os.Create(fmt.Sprintf("%s.tar.gz", outputPath))
if err != nil {
log.Error().Err(err).Str("path", outputPath).Msg("failed to write archive")
os.Exit(1)
}
files := make([]string, len(outputBytes))
i := 0
for path := range outputBytes {
files[i] = path
i++
}
err = util.CreateArchive(files, out)
if err != nil {
log.Error().Err(err).Str("path", outputPath).Msg("failed to create archive")
os.Exit(1)
}
} else if outputPath != "" && targetCount > 1 || templateCount > 1 {
// write multiple files in directory using template name
err := os.MkdirAll(filepath.Clean(outputPath), 0o755)
if err != nil {
log.Error().Err(err).Str("path", filepath.Clean(outputPath)).Msg("failed to make output directory")
os.Exit(1)
}
for path, contents := range outputBytes {
filename := filepath.Base(path)
cleanPath := fmt.Sprintf("%s/%s", filepath.Clean(outputPath), filename)
err := os.WriteFile(cleanPath, contents, 0o755)
if err != nil {
log.Error().Err(err).Str("path", path).Msg("failed to write config to file")
os.Exit(1)
}
log.Info().Msgf("wrote file to '%s'\n", cleanPath)
}
}
}
func init() {
generateCmd.Flags().StringSliceVar(&targets, "target", []string{}, "set the target configs to make")
generateCmd.Flags().StringSliceVar(&pluginPaths, "plugins", []string{}, "set the generator plugins directory path")
generateCmd.Flags().StringVarP(&outputPath, "output", "o", "", "set the output path for config targets")
generateCmd.Flags().StringVar(&cacertPath, "ca-cert", "", "path to CA cert. (defaults to system CAs)")
generateCmd.Flags().StringSliceVar(&targets, "target", []string{}, "set the targets to run pre-defined conf")
generateCmd.Flags().StringSliceVar(&templatePaths, "template", []string{}, "set the paths for the Jinja 2 templates to use")
generateCmd.Flags().StringVar(&pluginPath, "plugin", "", "set the generator plugin path")
generateCmd.Flags().StringVarP(&outputPath, "output", "o", "", "set the output path for conf targets")
generateCmd.Flags().IntVar(&tokenFetchRetries, "fetch-retries", 5, "set the number of retries to fetch an access token")
generateCmd.Flags().StringVar(&remoteHost, "host", "http://localhost", "set the remote host")
generateCmd.Flags().BoolVar(&useCompression, "compress", false, "set whether to archive and compress multiple file outputs")
// requires either 'target' by itself or 'plugin' and 'templates' together
// generateCmd.MarkFlagsOneRequired("target", "plugin")
generateCmd.MarkFlagsMutuallyExclusive("target", "plugin")
generateCmd.MarkFlagsMutuallyExclusive("target", "template")
generateCmd.MarkFlagsRequiredTogether("plugin", "template")
rootCmd.AddCommand(generateCmd)
}

View file

@ -7,6 +7,7 @@ import (
"strings"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/util"
"github.com/rodaine/table"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
@ -26,11 +27,16 @@ var inspectCmd = &cobra.Command{
return strings.ToUpper(fmt.Sprintf(format, vals...))
}
// TODO: remove duplicate args from CLI
// remove duplicate clean paths from CLI
paths := make([]string, len(args))
for _, path := range args {
paths = append(paths, filepath.Clean(path))
}
paths = util.RemoveDuplicates(paths)
// load specific plugins from positional args
var generators = make(map[string]generator.Generator)
for _, path := range args {
for _, path := range paths {
err := filepath.Walk(path, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err

View file

@ -4,22 +4,26 @@ import (
"fmt"
"os"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var (
configPath string
config configurator.Config
verbose bool
targets []string
outputPath string
conf config.Config
configPath string
cacertPath string
verbose bool
targets []string
outputPath string
accessToken string
remoteHost string
)
var rootCmd = &cobra.Command{
Use: "configurator",
Short: "Tool for building common config files",
Short: "Dynamically generate files defined by generators",
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
cmd.Help()
@ -36,24 +40,30 @@ func Execute() {
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "./config.yaml", "set the config path")
cobra.OnInitialize(InitConfig)
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "set the config path")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "set to enable verbose output")
rootCmd.PersistentFlags().StringVar(&cacertPath, "cacert", "", "path to CA cert. (defaults to system CAs)")
}
func initConfig() {
func InitConfig() {
// empty from not being set
if configPath != "" {
exists, err := util.PathExists(configPath)
if err != nil {
fmt.Printf("failed to load config")
log.Error().Err(err).Str("path", configPath).Msg("failed to load config")
os.Exit(1)
} else if exists {
config = configurator.LoadConfig(configPath)
conf = config.Load(configPath)
} else {
config = configurator.NewConfig()
// show error and exit since a path was specified
log.Error().Str("path", configPath).Msg("config file not found")
os.Exit(1)
}
} else {
config = configurator.NewConfig()
// set to the default value and create a new one
configPath = "./config.yaml"
conf = config.New()
}
//
@ -63,6 +73,6 @@ func initConfig() {
// set the JWKS url if we find the CONFIGURATOR_JWKS_URL environment variable
jwksUrl := os.Getenv("CONFIGURATOR_JWKS_URL")
if jwksUrl != "" {
config.Server.Jwks.Uri = jwksUrl
conf.Server.Jwks.Uri = jwksUrl
}
}

View file

@ -10,8 +10,8 @@ import (
"net/http"
"os"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/server"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
@ -20,67 +20,48 @@ var serveCmd = &cobra.Command{
Short: "Start configurator as a server and listen for requests",
Run: func(cmd *cobra.Command, args []string) {
// make sure that we have a token present before trying to make request
if config.AccessToken == "" {
// TODO: make request to check if request will need token
// check if OCHAMI_ACCESS_TOKEN env var is set if no access token is provided and use that instead
if conf.AccessToken == "" {
// check if ACCESS_TOKEN env var is set if no access token is provided and use that instead
accessToken := os.Getenv("ACCESS_TOKEN")
if accessToken != "" {
config.AccessToken = accessToken
conf.AccessToken = accessToken
} else {
// TODO: try and fetch token first if it is needed
if verbose {
fmt.Printf("No token found. Attempting to generate config without one...\n")
log.Warn().Msg("No token found. Continuing without one...\n")
}
}
}
// use config plugins if none supplied via CLI
if len(pluginPaths) <= 0 {
pluginPaths = append(pluginPaths, config.PluginDirs...)
}
// show config as JSON and generators if verbose
if verbose {
b, err := json.MarshalIndent(config, "", " ")
b, err := json.MarshalIndent(conf, "", "\t")
if err != nil {
fmt.Printf("failed to marshal config: %v\n", err)
log.Error().Err(err).Msg("failed to marshal config")
os.Exit(1)
}
fmt.Printf("%v\n", string(b))
}
// set up the routes and start the server
server := server.Server{
Config: &config,
Server: &http.Server{
Addr: fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port),
},
Jwks: server.Jwks{
Uri: config.Server.Jwks.Uri,
Retries: config.Server.Jwks.Retries,
},
GeneratorParams: generator.Params{
Args: args,
PluginPaths: pluginPaths,
// Target: target, // NOTE: targets are set via HTTP requests (ex: curl http://configurator:3334/generate?target=dnsmasq)
Verbose: verbose,
},
}
err := server.Serve()
// start listening with the server
var (
s *server.Server = server.New(&conf)
err error = s.Serve()
)
if errors.Is(err, http.ErrServerClosed) {
fmt.Printf("Server closed.")
if verbose {
log.Info().Msg("server closed")
}
} else if err != nil {
fmt.Errorf("failed to start server: %v", err)
log.Error().Err(err).Msg("failed to start server")
os.Exit(1)
}
},
}
func init() {
serveCmd.Flags().StringVar(&config.Server.Host, "host", config.Server.Host, "set the server host")
serveCmd.Flags().IntVar(&config.Server.Port, "port", config.Server.Port, "set the server port")
serveCmd.Flags().StringSliceVar(&pluginPaths, "plugins", nil, "set the generator plugins directory path")
serveCmd.Flags().StringVar(&config.Server.Jwks.Uri, "jwks-uri", config.Server.Jwks.Uri, "set the JWKS url to fetch public key")
serveCmd.Flags().IntVar(&config.Server.Jwks.Retries, "jwks-fetch-retries", config.Server.Jwks.Retries, "set the JWKS fetch retry count")
serveCmd.Flags().StringVar(&conf.Server.Host, "host", conf.Server.Host, "set the server host and port")
// serveCmd.Flags().StringVar(&pluginPath, "plugin", "", "set the generator plugins directory path")
serveCmd.Flags().StringVar(&conf.Server.Jwks.Uri, "jwks-uri", conf.Server.Jwks.Uri, "set the JWKS url to fetch public key")
serveCmd.Flags().IntVar(&conf.Server.Jwks.Retries, "jwks-fetch-retries", conf.Server.Jwks.Retries, "set the JWKS fetch retry count")
rootCmd.AddCommand(serveCmd)
}

23
examples/plugin/test.go Normal file
View file

@ -0,0 +1,23 @@
package main
import (
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/generator"
)
type TestGenerator struct{}
func (g *TestGenerator) GetName() string { return "test" }
func (g *TestGenerator) GetVersion() string { return "v1.0.0" }
func (g *TestGenerator) GetDescription() string {
return "This is a plugin creating for running tests."
}
func (g *TestGenerator) Generate(config *config.Config, params generator.Params) (generator.FileMap, error) {
return generator.ApplyTemplates(generator.Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
}, params.Templates)
}
var Generator TestGenerator

View file

@ -7,4 +7,4 @@
# Source code: https://github.com/OpenCHAMI/configurator
# Creating plugins: https://github.com/OpenCHAMI/configurator/blob/main/README.md#creating-generator-plugins
#
{{ dhcp-hosts }}
{{ dhcp_hosts }}

View file

@ -0,0 +1,16 @@
#
# This file was auto-generated by the OpenCHAMI "configurator" tool using the following plugin:
# Name: {{ plugin_name }}
# Version: {{ plugin_version }}
# Description: {{ plugin_description }}
#
# Source code: https://github.com/OpenCHAMI/configurator
# Creating plugins: https://github.com/OpenCHAMI/configurator/blob/main/README.md#creating-generator-plugins
#
# TODO: test variables
# TODO: test if/else statements
# TODO: test for loops

View file

@ -8,26 +8,9 @@ import (
"slices"
"github.com/OpenCHAMI/jwtauth/v5"
"github.com/lestrrat-go/jwx/jwk"
"github.com/lestrrat-go/jwx/v2/jwk"
)
func VerifyClaims(testClaims []string, r *http.Request) (bool, error) {
// extract claims from JWT
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
return false, fmt.Errorf("failed to get claims(s) from token: %v", err)
}
// verify that each one of the test claims are included
for _, testClaim := range testClaims {
_, ok := claims[testClaim]
if !ok {
return false, fmt.Errorf("failed to verify claim(s) from token: %s", testClaim)
}
}
return true, nil
}
func VerifyScope(testScopes []string, r *http.Request) (bool, error) {
// extract the scopes from JWT
var scopes []string
@ -112,3 +95,7 @@ func FetchPublicKeyFromURL(url string) (*jwtauth.JWTAuth, error) {
return tokenAuth, nil
}
func LoadAccessToken() {
}

66
pkg/client/client.go Normal file
View file

@ -0,0 +1,66 @@
package client
import (
"crypto/tls"
"crypto/x509"
"net"
"net/http"
"os"
"time"
)
type Option func(*Params)
type Params struct {
Host string `yaml:"host"`
AccessToken string `yaml:"access-token"`
Transport *http.Transport
}
func ToParams(opts ...Option) *Params {
params := &Params{}
for _, opt := range opts {
opt(params)
}
return params
}
func WithHost(host string) Option {
return func(c *Params) {
c.Host = host
}
}
func WithAccessToken(token string) Option {
return func(c *Params) {
c.AccessToken = token
}
}
func WithCertPool(certPool *x509.CertPool) Option {
return func(c *Params) {
c.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
InsecureSkipVerify: true,
},
DisableKeepAlives: true,
Dial: (&net.Dialer{
Timeout: 120 * time.Second,
KeepAlive: 120 * time.Second,
}).Dial,
TLSHandshakeTimeout: 120 * time.Second,
ResponseHeaderTimeout: 120 * time.Second,
}
}
}
// FIXME: Need to check for errors when reading from a file
func WithCertPoolFile(certPath string) Option {
if certPath == "" {
return func(sc *Params) {}
}
cacert, _ := os.ReadFile(certPath)
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(cacert)
return WithCertPool(certPool)
}

View file

@ -1,128 +1,64 @@
package configurator
package client
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"time"
"github.com/OpenCHAMI/configurator/pkg/util"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/rs/zerolog/log"
)
type ClientOption func(*SmdClient)
// An struct that's meant to extend functionality of the base HTTP client by
// adding commonly made requests to SMD. The implemented functions are can be
// used in generator plugins to fetch data when it is needed to substitute
// values for the Jinja templates used.
type SmdClient struct {
http.Client `json:"-"`
http.Client `json:"-" yaml:"-"`
Host string `yaml:"host"`
Port int `yaml:"port"`
AccessToken string `yaml:"access-token"`
}
// Constructor function that allows supplying ClientOption arguments to set
// Constructor function that allows supplying Option arguments to set
// things like the host, port, access token, etc.
func NewSmdClient(opts ...ClientOption) SmdClient {
client := SmdClient{}
for _, opt := range opts {
opt(&client)
}
return client
}
func WithHost(host string) ClientOption {
return func(c *SmdClient) {
c.Host = host
}
}
func WithPort(port int) ClientOption {
return func(c *SmdClient) {
c.Port = port
}
}
func WithAccessToken(token string) ClientOption {
return func(c *SmdClient) {
c.AccessToken = token
}
}
func WithCertPool(certPool *x509.CertPool) ClientOption {
return func(c *SmdClient) {
c.Client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
InsecureSkipVerify: true,
},
DisableKeepAlives: true,
Dial: (&net.Dialer{
Timeout: 120 * time.Second,
KeepAlive: 120 * time.Second,
}).Dial,
TLSHandshakeTimeout: 120 * time.Second,
ResponseHeaderTimeout: 120 * time.Second,
func NewSmdClient(opts ...Option) SmdClient {
var (
params = ToParams(opts...)
client = SmdClient{
Host: params.Host,
AccessToken: params.AccessToken,
}
}
}
)
// FIXME: Need to check for errors when reading from a file
func WithCertPoolFile(certPath string) ClientOption {
if certPath == "" {
return func(sc *SmdClient) {}
}
cacert, _ := os.ReadFile(certPath)
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(cacert)
return WithCertPool(certPool)
}
func WithVerbosity() util.Option {
return func(p util.Params) {
p["verbose"] = true
}
}
// Create a set of params with all default values.
func NewParams() util.Params {
return util.Params{
"verbose": false,
}
return client
}
// Fetch the ethernet interfaces from SMD service using its API. An access token may be required if the SMD
// service SMD_JWKS_URL envirnoment variable is set.
func (client *SmdClient) FetchEthernetInterfaces(opts ...util.Option) ([]EthernetInterface, error) {
func (client *SmdClient) FetchEthernetInterfaces(verbose bool) ([]configurator.EthernetInterface, error) {
var (
params = util.ToDict(opts...)
verbose = util.Get[bool](params, "verbose")
eths = []EthernetInterface{}
eths = []configurator.EthernetInterface{}
bytes []byte
err error
)
// make request to SMD endpoint
b, err := client.makeRequest("/Inventory/EthernetInterfaces")
bytes, err = client.makeRequest("/Inventory/EthernetInterfaces")
if err != nil {
return nil, fmt.Errorf("failed to read HTTP response: %v", err)
}
// unmarshal response body JSON and extract in object
err = json.Unmarshal(b, &eths)
err = json.Unmarshal(bytes, &eths)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
// print what we got if verbose is set
if verbose != nil {
if *verbose {
fmt.Printf("Ethernet Interfaces: %v\n", string(b))
}
if verbose {
log.Info().Str("ethernet_interfaces", string(bytes)).Msg("found interfaces")
}
return eths, nil
@ -130,68 +66,68 @@ func (client *SmdClient) FetchEthernetInterfaces(opts ...util.Option) ([]Etherne
// Fetch the components from SMD using its API. An access token may be required if the SMD
// service SMD_JWKS_URL envirnoment variable is set.
func (client *SmdClient) FetchComponents(opts ...util.Option) ([]Component, error) {
func (client *SmdClient) FetchComponents(verbose bool) ([]configurator.Component, error) {
var (
params = util.ToDict(opts...)
verbose = util.Get[bool](params, "verbose")
comps = []Component{}
comps = []configurator.Component{}
bytes []byte
err error
)
// make request to SMD endpoint
b, err := client.makeRequest("/State/Components")
bytes, err = client.makeRequest("/State/Components")
if err != nil {
return nil, fmt.Errorf("failed to make HTTP request: %v", err)
}
// make sure our response is actually JSON
if !json.Valid(b) {
return nil, fmt.Errorf("expected valid JSON response: %v", string(b))
if !json.Valid(bytes) {
return nil, fmt.Errorf("expected valid JSON response: %v", string(bytes))
}
// unmarshal response body JSON and extract in object
var tmp map[string]any
err = json.Unmarshal(b, &tmp)
err = json.Unmarshal(bytes, &tmp)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
b, err = json.Marshal(tmp["RedfishEndpoints"].([]any))
bytes, err = json.Marshal(tmp["RedfishEndpoints"].([]any))
if err != nil {
return nil, fmt.Errorf("failed to marshal JSON: %v", err)
}
err = json.Unmarshal(b, &comps)
err = json.Unmarshal(bytes, &comps)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
// print what we got if verbose is set
if verbose != nil {
if *verbose {
fmt.Printf("Components: %v\n", string(b))
}
if verbose {
log.Info().Str("components", string(bytes)).Msg("found components")
}
return comps, nil
}
func (client *SmdClient) FetchRedfishEndpoints(opts ...util.Option) ([]RedfishEndpoint, error) {
// TODO: improve implementation of this function
func (client *SmdClient) FetchRedfishEndpoints(verbose bool) ([]configurator.RedfishEndpoint, error) {
var (
params = util.ToDict(opts...)
verbose = util.Get[bool](params, "verbose")
eps = []RedfishEndpoint{}
eps = []configurator.RedfishEndpoint{}
tmp map[string]any
)
// make initial request to get JSON with 'RedfishEndpoints' as property
b, err := client.makeRequest("/Inventory/RedfishEndpoints")
if err != nil {
return nil, fmt.Errorf("failed to make HTTP resquest: %v", err)
}
// make sure response is in JSON
if !json.Valid(b) {
return nil, fmt.Errorf("expected valid JSON response: %v", string(b))
}
var tmp map[string]any
err = json.Unmarshal(b, &tmp)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
// marshal RedfishEndpoint JSON back to configurator.RedfishEndpoint
b, err = json.Marshal(tmp["RedfishEndpoints"].([]any))
if err != nil {
return nil, fmt.Errorf("failed to marshal JSON: %v", err)
@ -201,10 +137,9 @@ func (client *SmdClient) FetchRedfishEndpoints(opts ...util.Option) ([]RedfishEn
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
if verbose != nil {
if *verbose {
fmt.Printf("Redfish endpoints: %v\n", string(b))
}
// show the final result
if verbose {
log.Info().Str("redfish_endpoints", string(b)).Msg("found redfish endpoints")
}
return eps, nil
@ -216,7 +151,7 @@ func (client *SmdClient) makeRequest(endpoint string) ([]byte, error) {
}
// fetch DHCP related information from SMD's endpoint:
url := fmt.Sprintf("%s:%d/hsm/v2%s", client.Host, client.Port, endpoint)
url := fmt.Sprintf("%s/hsm/v2%s", client.Host, endpoint)
req, err := http.NewRequest(http.MethodGet, url, bytes.NewBuffer([]byte{}))
if err != nil {
return nil, fmt.Errorf("failed to create new HTTP request: %v", err)

View file

@ -1,129 +0,0 @@
package configurator
import (
"log"
"os"
"path/filepath"
"gopkg.in/yaml.v2"
)
type Options struct{}
type Target struct {
PluginPath string `yaml:"plugin,omitempty"`
TemplatePaths []string `yaml:"templates,omitempty"`
FilePaths []string `yaml:"files,omitempty"`
RunTargets []string `yaml:"targets,omitempty"`
}
type Jwks struct {
Uri string `yaml:"uri"`
Retries int `yaml:"retries,omitempty"`
}
type Server struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Jwks Jwks `yaml:"jwks,omitempty"`
}
type Config struct {
Version string `yaml:"version,omitempty"`
Server Server `yaml:"server,omitempty"`
SmdClient SmdClient `yaml:"smd,omitempty"`
AccessToken string `yaml:"access-token,omitempty"`
Targets map[string]Target `yaml:"targets,omitempty"`
PluginDirs []string `yaml:"plugins,omitempty"`
CertPath string `yaml:"cacert,omitempty"`
Options Options `yaml:"options,omitempty"`
}
// Creates a new config with default parameters.
func NewConfig() Config {
return Config{
Version: "",
SmdClient: SmdClient{
Host: "http://127.0.0.1",
Port: 27779,
},
Targets: map[string]Target{
"dnsmasq": Target{
PluginPath: "",
TemplatePaths: []string{},
},
"conman": Target{
PluginPath: "",
TemplatePaths: []string{},
},
"warewulf": Target{
PluginPath: "",
TemplatePaths: []string{
"templates/warewulf/defaults/node.jinja",
"templates/warewulf/defaults/provision.jinja",
},
},
},
PluginDirs: []string{},
Server: Server{
Host: "127.0.0.1",
Port: 3334,
Jwks: Jwks{
Uri: "",
Retries: 5,
},
},
Options: Options{},
}
}
func LoadConfig(path string) Config {
var c Config = NewConfig()
file, err := os.ReadFile(path)
if err != nil {
log.Printf("failed to read config file: %v\n", err)
return c
}
err = yaml.Unmarshal(file, &c)
if err != nil {
log.Fatalf("failed to unmarshal config: %v\n", err)
return c
}
return c
}
func (config *Config) SaveConfig(path string) {
path = filepath.Clean(path)
if path == "" || path == "." {
path = "config.yaml"
}
data, err := yaml.Marshal(config)
if err != nil {
log.Printf("failed to marshal config: %v\n", err)
return
}
err = os.WriteFile(path, data, os.ModePerm)
if err != nil {
log.Printf("failed to write default config file: %v\n", err)
return
}
}
func SaveDefaultConfig(path string) {
path = filepath.Clean(path)
if path == "" || path == "." {
path = "config.yaml"
}
var c = NewConfig()
data, err := yaml.Marshal(c)
if err != nil {
log.Printf("failed to marshal config: %v\n", err)
return
}
err = os.WriteFile(path, data, os.ModePerm)
if err != nil {
log.Printf("failed to write default config file: %v\n", err)
return
}
}

100
pkg/config/config.go Normal file
View file

@ -0,0 +1,100 @@
package config
import (
"os"
"path/filepath"
"github.com/rs/zerolog/log"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/client"
"gopkg.in/yaml.v2"
)
type Jwks struct {
Uri string `yaml:"uri"`
Retries int `yaml:"retries,omitempty"`
}
type Server struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Jwks Jwks `yaml:"jwks,omitempty"`
}
type Config struct {
Version string `yaml:"version,omitempty"`
Server Server `yaml:"server,omitempty"`
SmdClient client.SmdClient `yaml:"smd,omitempty"`
AccessToken string `yaml:"access-token,omitempty"`
Targets map[string]configurator.Target `yaml:"targets,omitempty"`
PluginDirs []string `yaml:"plugins,omitempty"`
CertPath string `yaml:"cacert,omitempty"`
}
// Creates a new config with default parameters.
func New() Config {
return Config{
Version: "",
SmdClient: client.SmdClient{Host: "http://127.0.0.1:27779"},
Targets: map[string]configurator.Target{},
PluginDirs: []string{},
Server: Server{
Host: "127.0.0.1:3334",
Jwks: Jwks{
Uri: "",
Retries: 5,
},
},
}
}
func Load(path string) Config {
var c Config = New()
file, err := os.ReadFile(path)
if err != nil {
log.Error().Err(err).Msg("failed to read config file")
return c
}
err = yaml.Unmarshal(file, &c)
if err != nil {
log.Error().Err(err).Msg("failed to unmarshal config")
return c
}
return c
}
func (config *Config) Save(path string) {
path = filepath.Clean(path)
if path == "" || path == "." {
path = "config.yaml"
}
data, err := yaml.Marshal(config)
if err != nil {
log.Error().Err(err).Msg("failed to marshal config")
return
}
err = os.WriteFile(path, data, 0o644)
if err != nil {
log.Error().Err(err).Msg("failed to write default config file")
return
}
}
func SaveDefault(path string) {
path = filepath.Clean(path)
if path == "" || path == "." {
path = "config.yaml"
}
var c = New()
data, err := yaml.Marshal(c)
if err != nil {
log.Error().Err(err).Msg("failed to marshal config")
return
}
err = os.WriteFile(path, data, 0o644)
if err != nil {
log.Error().Err(err).Msg("failed to write default config file")
return
}
}

View file

@ -2,6 +2,13 @@ package configurator
import "encoding/json"
type Target struct {
Plugin string `yaml:"plugin,omitempty"` // Set the plugin or it's path
TemplatePaths []string `yaml:"templates,omitempty"` // Set the template paths
FilePaths []string `yaml:"files,omitempty"` // Set the file paths
RunTargets []string `yaml:"targets,omitempty"` // Set additional targets to run
}
type IPAddr struct {
IpAddress string `json:"IPAddress"`
Network string `json:"Network"`

View file

@ -1,10 +1,11 @@
package main
package generator
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/client"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
@ -22,32 +23,20 @@ func (g *Conman) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *Conman) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
func (g *Conman) Generate(config *config.Config, params Params) (FileMap, error) {
var (
params = generator.GetParams(opts...)
client = generator.GetClient(params)
targetKey = params["targets"].(string) // required param
target = config.Targets[targetKey]
eps []configurator.RedfishEndpoint = nil
err error = nil
// serverOpts = ""
// globalOpts = ""
consoles = ""
smdClient = client.NewSmdClient(params.ClientOpts...)
eps = []configurator.RedfishEndpoint{}
err error = nil
consoles = ""
)
// fetch required data from SMD to create config
if client != nil {
eps, err = client.FetchRedfishEndpoints(opts...)
if err != nil {
return nil, fmt.Errorf("failed to fetch redfish endpoints with client: %v", err)
}
eps, err = smdClient.FetchRedfishEndpoints(params.Verbose)
if err != nil {
return nil, fmt.Errorf("failed to fetch redfish endpoints with client: %v", err)
}
// add any additional conman or server opts
// if extraOpts, ok := params["opts"].(map[string]any); ok {
// }
// format output to write to config file
consoles = "# ========== DYNAMICALLY GENERATED BY OPENCHAMI CONFIGURATOR ==========\n"
for _, ep := range eps {
@ -56,13 +45,12 @@ func (g *Conman) Generate(config *configurator.Config, opts ...util.Option) (gen
consoles += "# ====================================================================="
// apply template substitutions and return output as byte array
return generator.ApplyTemplateFromFiles(generator.Mappings{
return ApplyTemplates(Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
"server_opts": "",
"global_opts": "",
}, target.TemplatePaths...)
"consoles": consoles,
}, params.Templates)
}
var Generator Conman

View file

@ -1,10 +1,9 @@
package main
package generator
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
@ -22,8 +21,6 @@ func (g *CoreDhcp) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s' to generate config files. (WIP)", g.GetName())
}
func (g *CoreDhcp) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
func (g *CoreDhcp) Generate(config *config.Config, params Params) (FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}
var Generator CoreDhcp

64
pkg/generator/dhcpd.go Normal file
View file

@ -0,0 +1,64 @@
package generator
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/client"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
type DHCPd struct{}
func (g *DHCPd) GetName() string {
return "dhcpd"
}
func (g *DHCPd) GetVersion() string {
return util.GitCommit()
}
func (g *DHCPd) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *DHCPd) Generate(config *config.Config, params Params) (FileMap, error) {
var (
smdClient = client.NewSmdClient(params.ClientOpts...)
eths = []configurator.EthernetInterface{}
computeNodes = ""
err error = nil
)
//
eths, err = smdClient.FetchEthernetInterfaces(params.Verbose)
if err != nil {
return nil, fmt.Errorf("failed to fetch ethernet interfaces with client: %w", err)
}
// check if we have the required params first
if eths == nil {
return nil, fmt.Errorf("invalid ethernet interfaces (variable is nil)")
}
if len(eths) <= 0 {
return nil, fmt.Errorf("no ethernet interfaces found")
}
// format output to write to config file
computeNodes = "# ========== DYNAMICALLY GENERATED BY OPENCHAMI CONFIGURATOR ==========\n"
for _, eth := range eths {
if len(eth.IpAddresses) == 0 {
continue
}
computeNodes += fmt.Sprintf("host %s { hardware ethernet %s; fixed-address %s} ", eth.ComponentId, eth.MacAddress, eth.IpAddresses[0])
}
computeNodes += "# ====================================================================="
return ApplyTemplates(Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
"compute_nodes": computeNodes,
"node_entries": "",
}, params.Templates)
}

View file

@ -1,29 +1,29 @@
package main
package generator
import (
"fmt"
"strings"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/client"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
type DnsMasq struct{}
type DNSMasq struct{}
func (g *DnsMasq) GetName() string {
func (g *DNSMasq) GetName() string {
return "dnsmasq"
}
func (g *DnsMasq) GetVersion() string {
func (g *DNSMasq) GetVersion() string {
return util.GitCommit()
}
func (g *DnsMasq) GetDescription() string {
func (g *DNSMasq) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *DnsMasq) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
func (g *DNSMasq) Generate(config *config.Config, params Params) (FileMap, error) {
// make sure we have a valid config first
if config == nil {
return nil, fmt.Errorf("invalid config (config is nil)")
@ -31,20 +31,15 @@ func (g *DnsMasq) Generate(config *configurator.Config, opts ...util.Option) (ge
// set all the defaults for variables
var (
params = generator.GetParams(opts...)
client = generator.GetClient(params)
targetKey = params["target"].(string) // required param
target = config.Targets[targetKey]
eths []configurator.EthernetInterface = nil
err error = nil
smdClient = client.NewSmdClient(params.ClientOpts...)
eths = []configurator.EthernetInterface{}
err error = nil
)
// if we have a client, try making the request for the ethernet interfaces
if client != nil {
eths, err = client.FetchEthernetInterfaces(opts...)
if err != nil {
return nil, fmt.Errorf("failed to fetch ethernet interfaces with client: %v", err)
}
eths, err = smdClient.FetchEthernetInterfaces(params.Verbose)
if err != nil {
return nil, fmt.Errorf("failed to fetch ethernet interfaces with client: %v", err)
}
// check if we have the required params first
@ -55,13 +50,6 @@ func (g *DnsMasq) Generate(config *configurator.Config, opts ...util.Option) (ge
return nil, fmt.Errorf("no ethernet interfaces found")
}
// print message if verbose param found
if verbose, ok := params["verbose"].(bool); ok {
if verbose {
fmt.Printf("template: \n%s\nethernet interfaces found: %v\n", strings.Join(target.TemplatePaths, "\n\t"), len(eths))
}
}
// format output to write to config file
output := "# ========== DYNAMICALLY GENERATED BY OPENCHAMI CONFIGURATOR ==========\n"
for _, eth := range eths {
@ -74,12 +62,10 @@ func (g *DnsMasq) Generate(config *configurator.Config, opts ...util.Option) (ge
output += "# ====================================================================="
// apply template substitutions and return output as byte array
return generator.ApplyTemplateFromFiles(generator.Mappings{
return ApplyTemplates(Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
"dhcp-hosts": output,
}, target.TemplatePaths...)
"dhcp_hosts": output,
}, params.Templates)
}
var Generator DnsMasq

View file

@ -1,10 +1,9 @@
package main
package generator
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
@ -24,11 +23,9 @@ func (g *Example) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *Example) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
func (g *Example) Generate(config *config.Config, params Params) (FileMap, error) {
g.Message = `
This is an example generator plugin. See the file in 'internal/generator/plugins/example/example.go' on
information about constructing plugins and plugin requirements.`
return generator.FileMap{"example": []byte(g.Message)}, nil
return FileMap{"example": []byte(g.Message)}, nil
}
var Generator Example

View file

@ -1,39 +1,47 @@
package generator
import (
"bytes"
"fmt"
"maps"
"io/fs"
"os"
"path/filepath"
"plugin"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/client"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
"github.com/nikolalohinski/gonja/v2"
"github.com/nikolalohinski/gonja/v2/exec"
"github.com/rs/zerolog/log"
)
type Mappings map[string]any
type FileMap map[string][]byte
type FileList [][]byte
type (
Mappings map[string]any
FileMap map[string][]byte
FileList [][]byte
// Generator interface used to define how files are created. Plugins can
// be created entirely independent of the main driver program.
type Generator interface {
GetName() string
GetVersion() string
GetDescription() string
Generate(config *configurator.Config, opts ...util.Option) (FileMap, error)
}
// Generator interface used to define how files are created. Plugins can
// be created entirely independent of the main driver program.
Generator interface {
GetName() string
GetVersion() string
GetDescription() string
Generate(config *config.Config, params Params) (FileMap, error)
}
)
// Params defined and used by the "generate" subcommand.
type Params struct {
Args []string
PluginPaths []string
Generators map[string]Generator
Target string
Verbose bool
var DefaultGenerators = createDefaultGenerators()
func createDefaultGenerators() map[string]Generator {
var (
generatorMap = map[string]Generator{}
generators = []Generator{
&Conman{}, &DHCPd{}, &DNSMasq{}, &Warewulf{}, &Example{}, &CoreDhcp{},
}
)
for _, g := range generators {
generatorMap[g.GetName()] = g
}
return generatorMap
}
// Converts the file outputs from map[string][]byte to map[string]string.
@ -51,13 +59,13 @@ func LoadFiles(paths ...string) (FileMap, error) {
for _, path := range paths {
expandedPaths, err := filepath.Glob(path)
if err != nil {
return nil, fmt.Errorf("failed to glob path: %v", err)
return nil, fmt.Errorf("failed to glob path: %w", err)
}
for _, expandedPath := range expandedPaths {
info, err := os.Stat(expandedPath)
if err != nil {
fmt.Println(err)
return nil, fmt.Errorf("failed to stat file or directory: %v", err)
return nil, fmt.Errorf("failed to stat file or directory: %w", err)
}
// skip any directories found
if info.IsDir() {
@ -65,7 +73,7 @@ func LoadFiles(paths ...string) (FileMap, error) {
}
b, err := os.ReadFile(expandedPath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %v", err)
return nil, fmt.Errorf("failed to read file: %w", err)
}
outputs[expandedPath] = b
@ -81,19 +89,19 @@ func LoadPlugin(path string) (Generator, error) {
if isDir, err := util.IsDirectory(path); err == nil && isDir {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("failed to test if path is directory: %v", err)
return nil, fmt.Errorf("failed to test if plugin path is directory: %w", err)
}
// try and open the plugin
p, err := plugin.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open plugin: %v", err)
return nil, fmt.Errorf("failed to open plugin: %w", err)
}
// load the "Generator" symbol from plugin
symbol, err := p.Lookup("Generator")
if err != nil {
return nil, fmt.Errorf("failed to look up symbol at path '%s': %v", path, err)
return nil, fmt.Errorf("failed to look up symbol at path '%s': %w", path, err)
}
// assert that the plugin loaded has a valid generator
@ -108,153 +116,73 @@ func LoadPlugin(path string) (Generator, error) {
//
// Returns a map of generators. Each generator can be accessed by the name
// returned by the generator.GetName() implemented.
func LoadPlugins(dirpath string, opts ...util.Option) (map[string]Generator, error) {
func LoadPlugins(dirpath string, opts ...Option) (map[string]Generator, error) {
// check if verbose option is supplied
var (
gens = make(map[string]Generator)
params = util.GetParams(opts...)
generators = make(map[string]Generator)
params = ToParams(opts...)
)
items, _ := os.ReadDir(dirpath)
for _, item := range items {
if item.IsDir() {
subitems, _ := os.ReadDir(item.Name())
for _, subitem := range subitems {
if !subitem.IsDir() {
gen, err := LoadPlugin(subitem.Name())
if err != nil {
fmt.Printf("failed to load generator in directory '%s': %v\n", item.Name(), err)
continue
}
if verbose, ok := params["verbose"].(bool); ok {
if verbose {
fmt.Printf("-- found plugin '%s'\n", item.Name())
}
}
gens[gen.GetName()] = gen
}
}
} else {
gen, err := LoadPlugin(dirpath + item.Name())
if err != nil {
fmt.Printf("failed to load plugin: %v\n", err)
continue
}
if verbose, ok := params["verbose"].(bool); ok {
if verbose {
fmt.Printf("-- found plugin '%s'\n", dirpath+item.Name())
}
}
gens[gen.GetName()] = gen
//
err := filepath.Walk(dirpath, func(path string, info fs.FileInfo, err error) error {
// skip trying to load generator plugin if directory or error
if info.IsDir() || err != nil {
return nil
}
}
return gens, nil
}
// Option to specify "target" in parameter map. This is used to set which generator
// to use to generate a config file.
func WithTarget(target string) util.Option {
return func(p util.Params) {
if p != nil {
p["target"] = target
// only try loading if file has .so extension
if filepath.Ext(path) != ".so" {
return nil
}
}
}
// Option to specify "type" in parameter map. This is not currently used.
func WithType(_type string) util.Option {
return func(p util.Params) {
if p != nil {
p["type"] = _type
}
}
}
// Option to a specific client to include in implementing plugin generator.Generate().
//
// NOTE: This may be changed to pass some kind of client interface as an argument in
// the future instead.
func WithClient(client configurator.SmdClient) util.Option {
return func(p util.Params) {
p["client"] = client
}
}
// Helper function to get client in generator.Generate() plugin implementations.
func GetClient(params util.Params) *configurator.SmdClient {
return util.Get[configurator.SmdClient](params, "client")
}
// Helper function to get the target in generator.Generate() plugin implementations.
func GetTarget(config *configurator.Config, key string) configurator.Target {
return config.Targets[key]
}
// Helper function to load all options set with With*() into parameter map.
func GetParams(opts ...util.Option) util.Params {
params := util.Params{}
for _, opt := range opts {
opt(params)
}
return params
}
// Wrapper function to slightly abstract away some of the nuances with using gonja
// into a single function call. This function is *mostly* for convenience and
// simplication. If no paths are supplied, then no templates will be applied and
// there will be no output.
//
// The "FileList" returns a slice of byte arrays in the same order as the argument
// list supplied, but with the Jinja templating applied.
func ApplyTemplates(mappings Mappings, contents ...[]byte) (FileList, error) {
var (
data = exec.NewContext(mappings)
outputs = FileList{}
)
for _, b := range contents {
// load jinja template from file
t, err := gonja.FromBytes(b)
// load the generator plugin from current path
gen, err := LoadPlugin(path)
if err != nil {
return nil, fmt.Errorf("failed to read template from file: %v", err)
return fmt.Errorf("failed to load generator in directory '%s': %w", path, err)
}
// execute/render jinja template
b := bytes.Buffer{}
if err = t.Execute(&b, data); err != nil {
return nil, fmt.Errorf("failed to execute: %v", err)
// show the plugins found if verbose flag is set
if params.Verbose {
log.Info().Str("plugin_name", gen.GetName()).Msg("found plugin")
}
outputs = append(outputs, b.Bytes())
// map each generator plugin by name for lookup
generators[gen.GetName()] = gen
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
return outputs, nil
return generators, nil
}
// Wrapper function similiar to "ApplyTemplates" but takes file paths as arguments.
// This function will load templates from a file instead of using file contents.
func ApplyTemplateFromFiles(mappings Mappings, paths ...string) (FileMap, error) {
// Generate() is the main function to generate a collection of files and returns them as a map.
// This function only expects a path to a plugin and paths to a collection of templates to
// be used. This function will only load the plugin on-demand and fetch resources as needed.
//
// This function requires that a target and plugin path be set at minimum.
func Generate(config *config.Config, plugin string, params Params) (FileMap, error) {
var (
data = exec.NewContext(mappings)
outputs = FileMap{}
gen Generator
ok bool
err error
)
for _, path := range paths {
// load jinja template from file
t, err := gonja.FromFile(path)
// check if generator is built-in first before loading external plugin
log.Debug().Any("generators", DefaultGenerators).Msg("available generators")
gen, ok = DefaultGenerators[plugin]
if !ok {
// only load the plugin needed for this target if we don't find default
log.Error().Str("plugin", plugin).Msg("could not find target in default generators")
gen, err = LoadPlugin(plugin)
if err != nil {
return nil, fmt.Errorf("failed to read template from file: %v", err)
return nil, fmt.Errorf("failed to load plugin from file: %v", err)
}
// execute/render jinja template
b := bytes.Buffer{}
if err = t.Execute(&b, data); err != nil {
return nil, fmt.Errorf("failed to execute: %v", err)
}
outputs[path] = b.Bytes()
}
return outputs, nil
return gen.Generate(config, params)
}
// Main function to generate a collection of files as a map with the path as the key and
@ -266,53 +194,71 @@ func ApplyTemplateFromFiles(mappings Mappings, paths ...string) (FileMap, error)
// It is also call when running the configurator as a service with the "/generate" route.
//
// TODO: Separate loading plugins so we can load them once when running as a service.
func GenerateWithTarget(config *configurator.Config, params Params) (FileMap, error) {
func GenerateWithTarget(config *config.Config, target string) (FileMap, error) {
// load generator plugins to generate configs or to print
var (
generators = make(map[string]Generator)
client = configurator.NewSmdClient(
configurator.WithHost(config.SmdClient.Host),
configurator.WithPort(config.SmdClient.Port),
configurator.WithAccessToken(config.AccessToken),
configurator.WithCertPoolFile(config.CertPath),
)
opts []client.Option
targetInfo configurator.Target
generator Generator
params Params
err error
ok bool
)
// load all plugins from supplied arguments
for _, path := range params.PluginPaths {
if params.Verbose {
fmt.Printf("loading plugins from '%s'\n", path)
}
plugins, err := LoadPlugins(path)
if err != nil {
fmt.Printf("failed to load plugins: %v\n", err)
err = nil
continue
}
// add loaded generator plugins to set
maps.Copy(generators, plugins)
// check if a target is supplied
if target == "" {
return nil, fmt.Errorf("must specify a target")
}
// copy all generators supplied from arguments
maps.Copy(generators, params.Generators)
// load target information from config
targetInfo, ok = config.Targets[target]
if !ok {
log.Warn().Str("target", target).Msg("target not found in config")
}
// show available targets then exit
if len(params.Args) == 0 && params.Target == "" {
for g := range generators {
fmt.Printf("-- found generator plugin \"%s\"\n", g)
// if no plugin supplied in config target, then using the target supplied
if targetInfo.Plugin == "" {
targetInfo.Plugin = target
}
// check if generator is built-in first before loading
generator, ok = DefaultGenerators[target]
if !ok {
// only load the plugin needed for this target if we don't find default
log.Warn().Str("target", target).Msg("could not find target in default generators")
generator, err = LoadPlugin(targetInfo.Plugin)
if err != nil {
return nil, fmt.Errorf("failed to load plugin: %v", err)
}
return nil, nil
}
// check if there's at least one template available
if len(targetInfo.TemplatePaths) <= 0 {
return nil, fmt.Errorf("expects at least one template to be available")
}
// prepare params to pass into generator
params.Templates = map[string]Template{}
for _, templatePath := range targetInfo.TemplatePaths {
template := Template{}
template.LoadFromFile(templatePath)
params.Templates[templatePath] = template
}
// set the client options
if config.AccessToken != "" {
params.ClientOpts = append(opts, client.WithAccessToken(config.AccessToken))
}
if config.CertPath != "" {
params.ClientOpts = append(opts, client.WithCertPoolFile(config.CertPath))
}
// load files that are not to be copied
params.Files, err = LoadFiles(targetInfo.FilePaths...)
if err != nil {
return nil, fmt.Errorf("failed to load files to copy: %v", err)
}
// run the generator plugin from target passed
gen := generators[params.Target]
if gen == nil {
return nil, fmt.Errorf("invalid generator target (%s)", params.Target)
}
return gen.Generate(
config,
WithTarget(gen.GetName()),
WithClient(client),
)
return generator.Generate(config, params)
}

View file

@ -1,10 +1,9 @@
package main
package generator
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
@ -22,8 +21,6 @@ func (g *Hostfile) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *Hostfile) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
func (g *Hostfile) Generate(config *config.Config, opts ...Option) (FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}
var Generator Hostfile

43
pkg/generator/params.go Normal file
View file

@ -0,0 +1,43 @@
package generator
import (
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/client"
"github.com/OpenCHAMI/configurator/pkg/config"
)
type (
// Params used by the generator
Params struct {
Templates map[string]Template
Files map[string][]byte
ClientOpts []client.Option
Verbose bool
}
Option func(Params)
)
func ToParams(opts ...Option) Params {
params := Params{}
for _, opt := range opts {
opt(params)
}
return params
}
func WithClientOpts(opts ...client.Option) Option {
return func(p Params) {
p.ClientOpts = opts
}
}
func WithTemplates(templates map[string]Template) Option {
return func(p Params) {
p.Templates = templates
}
}
// Helper function to get the target in generator.Generate() plugin implementations.
func GetTarget(config *config.Config, key string) configurator.Target {
return config.Targets[key]
}

View file

@ -1,76 +0,0 @@
package main
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/util"
)
type Dhcpd struct{}
func (g *Dhcpd) GetName() string {
return "dhcpd"
}
func (g *Dhcpd) GetVersion() string {
return util.GitCommit()
}
func (g *Dhcpd) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *Dhcpd) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
var (
params = generator.GetParams(opts...)
client = generator.GetClient(params)
targetKey = params["target"].(string)
target = config.Targets[targetKey]
compute_nodes = ""
eths []configurator.EthernetInterface = nil
err error = nil
)
//
if client != nil {
eths, err = client.FetchEthernetInterfaces(opts...)
if err != nil {
return nil, fmt.Errorf("failed to fetch ethernet interfaces with client: %v", err)
}
}
// check if we have the required params first
if eths == nil {
return nil, fmt.Errorf("invalid ethernet interfaces (variable is nil)")
}
if len(eths) <= 0 {
return nil, fmt.Errorf("no ethernet interfaces found")
}
// format output to write to config file
compute_nodes = "# ========== DYNAMICALLY GENERATED BY OPENCHAMI CONFIGURATOR ==========\n"
for _, eth := range eths {
if len(eth.IpAddresses) == 0 {
continue
}
compute_nodes += fmt.Sprintf("host %s { hardware ethernet %s; fixed-address %s} ", eth.ComponentId, eth.MacAddress, eth.IpAddresses[0])
}
compute_nodes += "# ====================================================================="
if verbose, ok := params["verbose"].(bool); ok {
if verbose {
fmt.Printf("")
}
}
return generator.ApplyTemplateFromFiles(generator.Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
"compute_nodes": compute_nodes,
"node_entries": "",
}, target.TemplatePaths...)
}
var Generator Dhcpd

View file

@ -1,102 +0,0 @@
package main
import (
"fmt"
"maps"
"strings"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/util"
)
type Warewulf struct{}
func (g *Warewulf) GetName() string {
return "warewulf"
}
func (g *Warewulf) GetVersion() string {
return util.GitCommit()
}
func (g *Warewulf) GetDescription() string {
return "Configurator generator plugin for 'warewulf' config files."
}
func (g *Warewulf) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
var (
params = generator.GetParams(opts...)
client = generator.GetClient(params)
targetKey = params["target"].(string)
target = config.Targets[targetKey]
outputs = make(generator.FileMap, len(target.FilePaths)+len(target.TemplatePaths))
)
// check if our client is included and is valid
if client == nil {
return nil, fmt.Errorf("invalid client (client is nil)")
}
// if we have a client, try making the request for the ethernet interfaces
eths, err := client.FetchEthernetInterfaces(opts...)
if err != nil {
return nil, fmt.Errorf("failed to fetch ethernet interfaces with client: %v", err)
}
// check if we have the required params first
if eths == nil {
return nil, fmt.Errorf("invalid ethernet interfaces (variable is nil)")
}
if len(eths) <= 0 {
return nil, fmt.Errorf("no ethernet interfaces found")
}
// print message if verbose param found
if verbose, ok := params["verbose"].(bool); ok {
if verbose {
fmt.Printf("template: \n%s\n ethernet interfaces found: %v\n", strings.Join(target.TemplatePaths, "\n\t"), len(eths))
}
}
// fetch redfish endpoints and handle errors
eps, err := client.FetchRedfishEndpoints(opts...)
if err != nil {
return nil, fmt.Errorf("failed to fetch redfish endpoints: %v", err)
}
if len(eps) <= 0 {
return nil, fmt.Errorf("no redfish endpoints found")
}
// format output for template substitution
nodeEntries := ""
// load files and templates and copy to outputs
files, err := generator.LoadFiles(target.FilePaths...)
if err != nil {
return nil, fmt.Errorf("failed to load files: %v", err)
}
templates, err := generator.ApplyTemplateFromFiles(generator.Mappings{
"node_entries": nodeEntries,
}, target.TemplatePaths...)
if err != nil {
return nil, fmt.Errorf("failed to load templates: %v", err)
}
maps.Copy(outputs, files)
maps.Copy(outputs, templates)
// print message if verbose param is found
if verbose, ok := params["verbose"].(bool); ok {
if verbose {
fmt.Printf("templates and files loaded: \n")
for path, _ := range outputs {
fmt.Printf("\t%s", path)
}
}
}
return outputs, err
}
var Generator Warewulf

View file

@ -1,10 +1,9 @@
package main
package generator
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
@ -22,8 +21,6 @@ func (g *Powerman) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *Powerman) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
func (g *Powerman) Generate(config *config.Config, opts ...Option) (FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}
var Generator Powerman

View file

@ -1,10 +1,9 @@
package main
package generator
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
@ -22,8 +21,6 @@ func (g *Syslog) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *Syslog) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
func (g *Syslog) Generate(config *config.Config, opts ...Option) (FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}
var Generator Syslog

View file

@ -0,0 +1,98 @@
package generator
import (
"bytes"
"fmt"
"os"
"github.com/OpenCHAMI/configurator/pkg/util"
"github.com/nikolalohinski/gonja/v2"
"github.com/nikolalohinski/gonja/v2/exec"
"github.com/rs/zerolog/log"
)
type Template struct {
Contents []byte `json:"contents"`
}
func (t *Template) LoadFromFile(path string) error {
// skip loading template if path is a directory with no error
if isDir, err := util.IsDirectory(path); err == nil && isDir {
return nil
} else if err != nil {
return fmt.Errorf("failed to test if template path is directory: %w", err)
}
// try and read the contents of the file
// NOTE: we don't care if this is actually a Jinja template
// or not...at least for now.
contents, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file: %v", err)
}
t.Contents = contents
return nil
}
func (t *Template) IsEmpty() bool {
return len(t.Contents) <= 0
}
// Wrapper function to slightly abstract away some of the nuances with using gonja
// into a single function call. This function is *mostly* for convenience and
// simplication. If no paths are supplied, then no templates will be applied and
// there will be no output.
//
// The "FileList" returns a slice of byte arrays in the same order as the argument
// list supplied, but with the Jinja templating applied.
func ApplyTemplates(mappings Mappings, templates map[string]Template) (FileMap, error) {
var (
data = exec.NewContext(mappings)
outputs = FileMap{}
)
for path, template := range templates {
// load jinja template from file
t, err := gonja.FromBytes(template.Contents)
if err != nil {
return nil, fmt.Errorf("failed to read template from file: %w", err)
}
// execute/render jinja template
b := bytes.Buffer{}
if err = t.Execute(&b, data); err != nil {
return nil, fmt.Errorf("failed to execute: %w", err)
}
outputs[path] = b.Bytes()
}
log.Debug().Any("templates", templates).Any("outputs", outputs).Any("mappings", mappings).Msg("apply templates")
return outputs, nil
}
// Wrapper function similiar to "ApplyTemplates" but takes file paths as arguments.
// This function will load templates from a file instead of using file contents.
func ApplyTemplateFromFiles(mappings Mappings, paths ...string) (FileMap, error) {
var (
data = exec.NewContext(mappings)
outputs = FileMap{}
)
for _, path := range paths {
// load jinja template from file
t, err := gonja.FromFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read template from file: %w", err)
}
// execute/render jinja template
b := bytes.Buffer{}
if err = t.Execute(&b, data); err != nil {
return nil, fmt.Errorf("failed to execute: %w", err)
}
outputs[path] = b.Bytes()
}
return outputs, nil
}

78
pkg/generator/warewulf.go Normal file
View file

@ -0,0 +1,78 @@
package generator
import (
"fmt"
"maps"
"strings"
"github.com/OpenCHAMI/configurator/pkg/client"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
"github.com/rs/zerolog/log"
)
type Warewulf struct{}
func (g *Warewulf) GetName() string {
return "warewulf"
}
func (g *Warewulf) GetVersion() string {
return util.GitCommit()
}
func (g *Warewulf) GetDescription() string {
return "Configurator generator plugin for 'warewulf' config files."
}
func (g *Warewulf) Generate(config *config.Config, params Params) (FileMap, error) {
var (
smdClient = client.NewSmdClient(params.ClientOpts...)
outputs = make(FileMap, len(params.Templates))
nodeEntries = ""
paths = []string{}
)
// if we have a client, try making the request for the ethernet interfaces
eths, err := smdClient.FetchEthernetInterfaces(params.Verbose)
if err != nil {
return nil, fmt.Errorf("failed to fetch ethernet interfaces with client: %v", err)
}
// check if we have the required params first
if eths == nil {
return nil, fmt.Errorf("invalid ethernet interfaces (variable is nil)")
}
if len(eths) <= 0 {
return nil, fmt.Errorf("no ethernet interfaces found")
}
// fetch redfish endpoints and handle errors
eps, err := smdClient.FetchRedfishEndpoints(params.Verbose)
if err != nil {
return nil, fmt.Errorf("failed to fetch redfish endpoints: %v", err)
}
if len(eps) <= 0 {
return nil, fmt.Errorf("no redfish endpoints found")
}
templates, err := ApplyTemplates(Mappings{
"node_entries": nodeEntries,
}, params.Templates)
if err != nil {
return nil, fmt.Errorf("failed to load templates: %v", err)
}
maps.Copy(outputs, params.Files)
maps.Copy(outputs, templates)
// print message if verbose param is found
if params.Verbose {
for path, _ := range outputs {
paths = append(paths, path)
}
}
log.Info().Str("paths", strings.Join(paths, ":")).Msg("templates and files loaded: \n")
return outputs, err
}

View file

@ -6,17 +6,19 @@ package server
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/client"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/jwtauth/v5"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog"
"github.com/sirupsen/logrus"
openchami_authenticator "github.com/openchami/chi-middleware/auth"
openchami_logger "github.com/openchami/chi-middleware/log"
@ -33,42 +35,48 @@ type Jwks struct {
}
type Server struct {
*http.Server
Config *configurator.Config
Config *config.Config
Jwks Jwks `yaml:"jwks"`
GeneratorParams generator.Params
TokenAuth *jwtauth.JWTAuth
Targets map[string]Target
}
type Target struct {
Name string `json:"name"`
PluginPath string `json:"plugin"`
Templates []generator.Template `json:"templates"`
}
// Constructor to make a new server instance with an optional config.
func New(config *configurator.Config) *Server {
func New(conf *config.Config) *Server {
// create default config if none supplied
if config == nil {
c := configurator.NewConfig()
config = &c
if conf == nil {
c := config.New()
conf = &c
}
// return based on config values
return &Server{
Config: config,
Server: &http.Server{
Addr: fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port),
},
newServer := &Server{
Config: conf,
Server: &http.Server{Addr: conf.Server.Host},
Jwks: Jwks{
Uri: config.Server.Jwks.Uri,
Retries: config.Server.Jwks.Retries,
Uri: conf.Server.Jwks.Uri,
Retries: conf.Server.Jwks.Retries,
},
}
// load templates for server from config
newServer.loadTargets()
log.Debug().Any("targets", newServer.Targets).Msg("new server targets")
return newServer
}
// Main function to start up configurator as a service.
func (s *Server) Serve() error {
// create client just for the server to use to fetch data from SMD
_ = &configurator.SmdClient{
Host: s.Config.SmdClient.Host,
Port: s.Config.SmdClient.Port,
}
// Setup logger
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
logger := log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
// set the server address with config values
s.Server.Addr = fmt.Sprintf("%s:%d", s.Config.Server.Host, s.Config.Server.Port)
s.Server.Addr = s.Config.Server.Host
// fetch JWKS public key from authorization server
if s.Config.Server.Jwks.Uri != "" && tokenAuth == nil {
@ -76,16 +84,19 @@ func (s *Server) Serve() error {
var err error
tokenAuth, err = configurator.FetchPublicKeyFromURL(s.Config.Server.Jwks.Uri)
if err != nil {
logrus.Errorf("failed to fetch JWKS: %w", err)
log.Error().Err(err).Msgf("failed to fetch JWKS")
continue
}
break
}
}
// Setup logger
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
logger := log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
// create client with opts to use to fetch data from SMD
opts := []client.Option{
client.WithHost(s.Config.SmdClient.Host),
client.WithAccessToken(s.Config.AccessToken),
client.WithCertPoolFile(s.Config.CertPath),
}
// create new go-chi router with its routes
router := chi.NewRouter()
@ -104,16 +115,17 @@ func (s *Server) Serve() error {
)
// protected routes if using auth
r.HandleFunc("/generate", s.Generate)
r.HandleFunc("/templates", s.ManageTemplates)
r.HandleFunc("/generate", s.Generate(opts...))
r.Post("/targets", s.createTarget)
})
} else {
// public routes without auth
router.HandleFunc("/generate", s.Generate)
router.HandleFunc("/templates", s.ManageTemplates)
router.HandleFunc("/generate", s.Generate(opts...))
router.Post("/targets", s.createTarget)
}
// always available public routes go here (none at the moment)
router.HandleFunc("/configurator/status", s.GetStatus)
s.Handler = router
return s.ListenAndServe()
@ -127,51 +139,189 @@ func (s *Server) Close() {
// This is the corresponding service function to generate templated files, that
// works similarly to the CLI variant. This function takes similiar arguments as
// query parameters that are included in the HTTP request URL.
func (s *Server) Generate(w http.ResponseWriter, r *http.Request) {
// get all of the expect query URL params and validate
s.GeneratorParams.Target = r.URL.Query().Get("target")
if s.GeneratorParams.Target == "" {
writeErrorResponse(w, "must specify a target")
return
}
func (s *Server) Generate(opts ...client.Option) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// get all of the expect query URL params and validate
var (
targetParam string = r.URL.Query().Get("target")
target *Target = s.getTarget(targetParam)
outputs generator.FileMap
err error
)
s.GeneratorParams = parseGeneratorParams(r, target, opts...)
if targetParam == "" {
err = writeErrorResponse(w, "must specify a target")
log.Error().Err(err).Msg("failed to parse generator params")
return
}
// generate a new config file from supplied params
outputs, err := generator.GenerateWithTarget(s.Config, s.GeneratorParams)
if err != nil {
writeErrorResponse(w, "failed to generate file: %w", err)
return
}
// try to generate with target supplied by client first
if target != nil {
log.Debug().Any("target", target).Msg("target for Generate()")
outputs, err = generator.Generate(s.Config, target.PluginPath, s.GeneratorParams)
log.Debug().Any("outputs map", outputs).Msgf("after generate")
if err != nil {
log.Error().Err(err).Msg("failed to generate file")
return
}
} else {
// try and generate a new config file from supplied params
log.Debug().Str("target", targetParam).Msg("target for GenerateWithTarget()")
outputs, err = generator.GenerateWithTarget(s.Config, targetParam)
if err != nil {
writeErrorResponse(w, "failed to generate file")
log.Error().Err(err).Msgf("failed to generate file with target '%s'", target)
return
}
}
// marshal output to JSON then send response to client
tmp := generator.ConvertContentsToString(outputs)
b, err := json.Marshal(tmp)
if err != nil {
writeErrorResponse(w, "failed to marshal output: %w", err)
return
// marshal output to JSON then send response to client
tmp := generator.ConvertContentsToString(outputs)
b, err := json.Marshal(tmp)
if err != nil {
writeErrorResponse(w, "failed to marshal output: %v", err)
log.Error().Err(err).Msg("failed to marshal output")
return
}
_, err = w.Write(b)
if err != nil {
writeErrorResponse(w, "failed to write response: %v", err)
log.Error().Err(err).Msg("failed to write response")
return
}
}
_, err = w.Write(b)
}
func (s *Server) loadTargets() {
// make sure the map is initialized first
if s.Targets == nil {
s.Targets = make(map[string]Target)
}
// add default generator targets
for name, _ := range generator.DefaultGenerators {
serverTarget := Target{
Name: name,
PluginPath: name,
}
s.Targets[name] = serverTarget
}
// add targets from config to server (overwrites default targets)
for name, target := range s.Config.Targets {
serverTarget := Target{
Name: name,
}
// only overwrite plugin path if it's set
if target.Plugin != "" {
serverTarget.PluginPath = target.Plugin
} else {
serverTarget.PluginPath = name
}
// add templates using template paths from config
for _, templatePath := range target.TemplatePaths {
template := generator.Template{}
template.LoadFromFile(templatePath)
serverTarget.Templates = append(serverTarget.Templates, template)
}
s.Targets[name] = serverTarget
}
}
func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
data := map[string]any{
"code": 200,
"message": "Configurator is healthy",
}
err := json.NewEncoder(w).Encode(data)
if err != nil {
writeErrorResponse(w, "failed to write response: %w", err)
fmt.Printf("failed to encode JSON: %v\n", err)
return
}
}
// Incomplete WIP function for managing templates remotely. There is currently no
// internal API to do this yet.
// Create a new target with name, generator, templates, and files.
//
// Example:
//
// curl -X POST /target?name=test&plugin=dnsmasq
//
// TODO: need to implement template managing API first in "internal/generator/templates" or something
func (s *Server) ManageTemplates(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("this is not implemented yet"))
if err != nil {
writeErrorResponse(w, "failed to write response: %w", err)
func (s *Server) createTarget(w http.ResponseWriter, r *http.Request) {
var (
target = Target{}
bytes []byte
err error
)
if r == nil {
err = writeErrorResponse(w, "request is invalid")
log.Error().Err(err).Msg("request == nil")
return
}
bytes, err = io.ReadAll(r.Body)
if err != nil {
writeErrorResponse(w, "failed to read response body: %v", err)
log.Error().Err(err).Msg("failed to read response body")
return
}
defer r.Body.Close()
err = json.Unmarshal(bytes, &target)
if err != nil {
writeErrorResponse(w, "failed to unmarshal target: %v", err)
log.Error().Err(err).Msg("failed to unmarshal target")
return
}
// make sure a plugin and at least one template is supplied
if target.Name == "" {
err = writeErrorResponse(w, "target name is required")
log.Error().Err(err).Msg("set target as a URL query parameter")
return
}
if target.PluginPath == "" {
err = writeErrorResponse(w, "generator name is required")
log.Error().Err(err).Msg("must supply a generator name")
return
}
if len(target.Templates) <= 0 {
writeErrorResponse(w, "requires at least one template")
log.Error().Err(err).Msg("must supply at least one template")
return
}
s.Targets[target.Name] = target
}
func (s *Server) getTarget(name string) *Target {
t, ok := s.Targets[name]
if ok {
return &t
}
return nil
}
// Wrapper function to simplify writting error message responses. This function
// is only intended to be used with the service and nothing else.
func writeErrorResponse(w http.ResponseWriter, format string, a ...any) error {
errmsg := fmt.Sprintf(format, a...)
w.Write([]byte(errmsg))
bytes, _ := json.Marshal(map[string]any{
"level": "error",
"time": time.Now().Unix(),
"message": errmsg,
})
http.Error(w, string(bytes), http.StatusInternalServerError)
return fmt.Errorf(errmsg)
}
func parseGeneratorParams(r *http.Request, target *Target, opts ...client.Option) generator.Params {
var params = generator.Params{
ClientOpts: opts,
Templates: make(map[string]generator.Template, len(target.Templates)),
}
for i, template := range target.Templates {
params.Templates[fmt.Sprintf("%s_%d", target.Name, i)] = template
}
return params
}

View file

@ -11,7 +11,7 @@ type Params map[string]any
type Option func(Params)
// Extract all parameters from the options passed as map[string]any.
func GetParams(opts ...Option) Params {
func ToDict(opts ...Option) Params {
params := Params{}
for _, opt := range opts {
opt(params)
@ -45,8 +45,8 @@ func WithDefault[T any](v T) Option {
}
}
// Syntactic sugar generic function to get parameter from util.Params.
func Get[T any](params Params, key string, opts ...Option) *T {
// Sugary generic function to get parameter from util.Params.
func Get[T any](params Params, key string) *T {
if v, ok := params[key].(T); ok {
return &v
}
@ -55,3 +55,16 @@ func Get[T any](params Params, key string, opts ...Option) *T {
}
return nil
}
func GetOpt[T any](opts []Option, key string) *T {
return Get[T](ToDict(opts...), "required_claims")
}
func (p Params) GetVerbose() bool {
if verbose, ok := p["verbose"].(bool); ok {
return verbose
}
// default setting
return false
}

View file

@ -1,13 +1,17 @@
package util
import (
"archive/tar"
"bytes"
"cmp"
"compress/gzip"
"crypto/tls"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"slices"
"strings"
)
@ -28,7 +32,7 @@ func IsDirectory(path string) (bool, error) {
// This returns an *os.FileInfo type
fileInfo, err := os.Stat(path)
if err != nil {
return false, fmt.Errorf("failed to stat path: %v", err)
return false, fmt.Errorf("failed to stat path (%s): %v", path, err)
}
// IsDir is short for fileInfo.Mode().IsDir()
@ -63,7 +67,7 @@ func MakeRequest(url string, httpMethod string, body []byte, headers map[string]
// NOTE: This currently requires git to be installed.
// TODO: Change how this is done to not require executing a command.
func GitCommit() string {
c := exec.Command("git", "rev-parse", "HEAD")
c := exec.Command("git", "rev-parse", "--short=8", "HEAD")
stdout, err := c.Output()
if err != nil {
return ""
@ -80,6 +84,11 @@ func RemoveIndex[T comparable](s []T, index int) []T {
return append(ret, s[index+1:]...)
}
func RemoveDuplicates[T cmp.Ordered](s []T) []T {
slices.Sort(s)
return slices.Compact(s)
}
// General function to copy elements from slice if condition is true.
func CopyIf[T comparable](s []T, condition func(t T) bool) []T {
var f = make([]T, 0)
@ -90,3 +99,64 @@ func CopyIf[T comparable](s []T, condition func(t T) bool) []T {
}
return f
}
func CreateArchive(files []string, buf io.Writer) error {
// Create new Writers for gzip and tar
// These writers are chained. Writing to the tar writer will
// write to the gzip writer which in turn will write to
// the "buf" writer
gw := gzip.NewWriter(buf)
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
// Iterate over files and add them to the tar archive
for _, file := range files {
err := addToArchive(tw, file)
if err != nil {
return err
}
}
return nil
}
func addToArchive(tw *tar.Writer, filename string) error {
// open file to write to archive
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
// get FileInfo for file size, mode, etc.
info, err := file.Stat()
if err != nil {
return err
}
// create a tar Header from the FileInfo data
header, err := tar.FileInfoHeader(info, info.Name())
if err != nil {
return err
}
// use full path as name (FileInfoHeader only takes the basename) to
// preserve directory structure
// see for more info: https://golang.org/src/archive/tar/common.go?#L626
header.Name = filename
// Write file header to the tar archive
err = tw.WriteHeader(header)
if err != nil {
return err
}
// copy file content to tar archive
_, err = io.Copy(tw, file)
if err != nil {
return err
}
return nil
}

34
res/archlinux/PKGBUILD Normal file
View file

@ -0,0 +1,34 @@
# Maintainer: David J. Allen <allend@lanl.gov>
pkgname=configurator
pkgver=v0.1.0alpha
pkgrel=1
pkgdesc="An extensible tool to dynamically generate config files from SMD with Jinja 2 templating support."
arch=("x86_64")
url="https://github.com/OpenCHAMI/configurator"
license=('MIT')
groups=("openchami")
provides=('configurator')
conflicts=('configurator')
https://github.com/OpenCHAMI/configurator/releases/download/v0.1.0-alpha/configurator
source_x86_64=(
"${url}/releases/download/v0.1.0-alpha/${pkgname}.tar.gz"
)
sha256sums_x86_64=('28e10f1e39757bbdc3a503de74dd4d8c610d9c78e89665fb42012e8ef7834d0f')
# we don't need to set pkgver just yet for the pre-release version...
# pkgver() {
# cd "$srcdir" || exit
# printf "%s" "$(git describe --tags --abbrev=0)"
# }
package() {
cd "$srcdir/" || exit
# install the binary to /usr/bin
mkdir -p "${pkgdir}/usr/bin"
mkdir -p "${pkgdir}/usr/lib/${pkgname}"
install -m755 configurator "${pkgdir}/usr/bin/configurator"
# install plugins to /usr/lib
install -m755 *.so "${pkgdir}/usr/lib/${pkgname}"
}

22
tests/generate_local.hurl Normal file
View file

@ -0,0 +1,22 @@
##
## Run these tests after starting server with `configurator serve...`
##
# Generate a `example` config with default plugin and template
GET http://127.0.0.1:3334/generate?target=example
HTTP 200
# Create a new target using the API
POST http://127.0.0.1:3334/targets
{
"name": "test",
"plugin": "example",
"templates": [{
"contents": "This is an example template used with the example plugin."
}]
}
HTTP 200
# Test the new target just add from POST above
GET http://127.0.0.1:3334/generate?target=example
HTTP 200

View file

@ -3,17 +3,26 @@ package tests
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"testing"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/server"
"github.com/OpenCHAMI/configurator/pkg/util"
)
var (
workDir string
replaceDir string
err error
)
// A valid test generator that implements the `Generator` interface.
type TestGenerator struct{}
@ -22,98 +31,96 @@ func (g *TestGenerator) GetVersion() string { return "v1.0.0" }
func (g *TestGenerator) GetDescription() string {
return "This is a plugin created for running tests."
}
func (g *TestGenerator) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
func (g *TestGenerator) Generate(config *config.Config, params generator.Params) (generator.FileMap, error) {
// Jinja 2 template file
files := [][]byte{
[]byte(`
files := map[string]generator.Template{
"test1": generator.Template{
Contents: []byte(`
Name: {{plugin_name}}
Version: {{plugin_version}}
Description: {{plugin_description}}
This is the first test template file.
`),
[]byte(`
`),
},
"test2": generator.Template{
Contents: []byte(`
This is another testing Jinja 2 template file using {{plugin_name}}.
`),
`),
},
}
// apply Jinja templates to file
fileList, err := generator.ApplyTemplates(generator.Mappings{
fileMap, err := generator.ApplyTemplates(generator.Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
}, files...)
}, files)
if err != nil {
return nil, fmt.Errorf("failed to apply templates: %v", err)
}
// make sure we're able to receive certain arguments when passed
params := generator.GetParams(opts...)
if len(params) <= 0 {
return nil, fmt.Errorf("expect at least one params, but found none")
}
// make sure we have a valid config we can access
if config == nil {
return nil, fmt.Errorf("invalid config (config is nil)")
}
// make sure we're able to get a valid client as well
client := generator.GetClient(params)
if client == nil {
return nil, fmt.Errorf("invalid client (client is nil)")
}
// TODO: make sure we can get a target
// make sure we have the same number of files in file list
if len(files) != len(fileList) {
return nil, fmt.Errorf("file list output count is not the same as the input")
}
// convert file list to file map
fileMap := make(generator.FileMap, len(fileList))
for i, contents := range fileList {
fileMap[fmt.Sprintf("t-%d.txt", i)] = contents
var (
fileInputCount = len(files)
fileOutputCount = len(fileMap)
)
if fileInputCount != fileOutputCount {
return nil, fmt.Errorf("file output count (%d) is not the same as the input (%d)", fileOutputCount, fileInputCount)
}
return fileMap, nil
}
func init() {
workDir, err = os.Getwd()
if err != nil {
log.Fatalf("failed to get working directory: %v", err)
}
replaceDir = fmt.Sprintf("%s", filepath.Dir(workDir))
}
// Test building and loading plugins
func TestPlugin(t *testing.T) {
var (
testPluginDir = t.TempDir()
testPluginPath = fmt.Sprintf("%s/test-plugin.so", testPluginDir)
testPluginSourcePath = fmt.Sprintf("%s/test-plugin.go", testPluginDir)
testPluginSource = []byte(`
package main
testPluginSource = []byte(
`package main
import (
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/generator"
"github.com/OpenCHAMI/configurator/pkg/util"
)
type TestGenerator struct{}
func (g *TestGenerator) GetName() string { return "test" }
func (g *TestGenerator) GetVersion() string { return "v1.0.0" }
func (g *TestGenerator) GetDescription() string { return "This is a plugin creating for running tests." }
func (g *TestGenerator) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
func (g *TestGenerator) GetName() string { return "test" }
func (g *TestGenerator) GetVersion() string { return "v1.0.0" }
func (g *TestGenerator) GetDescription() string {
return "This is a plugin creating for running tests."
}
func (g *TestGenerator) Generate(config *config.Config, params generator.Params) (generator.FileMap, error) {
return generator.FileMap{"test": []byte("test")}, nil
}
var Generator TestGenerator
`)
var Generator TestGenerator`)
)
wd, err := os.Getwd()
if err != nil {
t.Errorf("failed to get working directory: %v", err)
}
// get directory to replace remote pkg with local
// _, filename, _, _ := runtime.Caller(0)
// replaceDir := fmt.Sprintf("%s", filepath.Dir(workDir))
// show all paths to make sure we're using the correct ones
fmt.Printf("(TestPlugin) working directory: %v\n", wd)
fmt.Printf("(TestPlugin) working directory: %v\n", workDir)
fmt.Printf("(TestPlugin) plugin directory: %v\n", testPluginDir)
fmt.Printf("(TestPlugin) plugin path: %v\n", testPluginPath)
fmt.Printf("(TestPlugin) plugin source path: %v\n", testPluginSourcePath)
@ -151,6 +158,12 @@ var Generator TestGenerator
t.Fatalf("failed to execute command: %v\n%s", err, string(output))
}
// use the local `pkg` instead of the release one
cmd = exec.Command("bash", "-c", fmt.Sprintf("go mod edit -replace=github.com/OpenCHAMI/configurator=%s", replaceDir))
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to execute command: %v\n%s", err, string(output))
}
// run `go mod tidy` for dependencies
cmd = exec.Command("bash", "-c", "go mod tidy")
if output, err := cmd.CombinedOutput(); err != nil {
@ -186,7 +199,7 @@ var Generator TestGenerator
GetName() string
GetVersion() string
GetDescription() string
Generate(*configurator.Config, ...util.Option) (generator.FileMap, error)
Generate(*config.Config, generator.Params) (generator.FileMap, error)
}); !ok {
t.Error("plugin does not implement all of the generator interface")
}
@ -203,7 +216,7 @@ var Generator TestGenerator
GetName() string
GetVersion() string
GetDescription() string
Generate(*configurator.Config, ...util.Option) (generator.FileMap, error)
Generate(*config.Config, generator.Params) (generator.FileMap, error)
}); !ok {
t.Error("plugin does not implement all of the generator interface")
}
@ -233,16 +246,15 @@ var Generator InvalidGenerator
`)
)
wd, err := os.Getwd()
if err != nil {
t.Errorf("failed to get working directory: %v", err)
}
// show all paths to make sure we're using the correct ones
fmt.Printf("(TestPluginWithInvalidOrNoSymbol) working directory: %v\n", wd)
fmt.Printf("(TestPluginWithInvalidOrNoSymbol) working directory: %v\n", workDir)
fmt.Printf("(TestPluginWithInvalidOrNoSymbol) plugin directory: %v\n", testPluginDir)
fmt.Printf("(TestPluginWithInvalidOrNoSymbol) plugin path: %v\n", testPluginPath)
fmt.Printf("(TestPluginWithInvalidOrNoSymbol) plugin source path: %v\n", testPluginSourcePath)
// get directory to replace remote pkg with local
// _, filename, _, _ := runtime.Caller(0)
// make temporary directory to test plugin
err = os.MkdirAll(testPluginDir, os.ModeDir)
if err != nil {
@ -276,6 +288,12 @@ var Generator InvalidGenerator
t.Fatalf("failed to execute command: %v\n%s", err, string(output))
}
// use the local `pkg` instead of the release one
cmd = exec.Command("bash", "-c", fmt.Sprintf("go mod edit -replace=github.com/OpenCHAMI/configurator=%s", replaceDir))
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to execute command: %v\n%s", err, string(output))
}
// run `go mod tidy` for dependencies
cmd = exec.Command("bash", "-c", "go mod tidy")
if output, err := cmd.CombinedOutput(); err != nil {
@ -314,9 +332,8 @@ var Generator InvalidGenerator
// we're not doing it here since that's not what is being tested.
func TestGenerateExample(t *testing.T) {
var (
config = configurator.NewConfig()
client = configurator.NewSmdClient()
gen = TestGenerator{}
conf = config.New()
gen = TestGenerator{}
)
// make sure our generator returns expected strings
@ -333,11 +350,7 @@ func TestGenerateExample(t *testing.T) {
})
// try to generate a file with templating applied
fileMap, err := gen.Generate(
&config,
generator.WithTarget("test"),
generator.WithClient(client),
)
fileMap, err := gen.Generate(&conf, generator.Params{})
if err != nil {
t.Fatalf("failed to generate file: %v", err)
}
@ -356,8 +369,7 @@ func TestGenerateExample(t *testing.T) {
// try and load the plugin from a lib here either.
func TestGenerateExampleWithServer(t *testing.T) {
var (
config = configurator.NewConfig()
client = configurator.NewSmdClient()
conf = config.New()
gen = TestGenerator{}
headers = make(map[string]string, 0)
)
@ -365,16 +377,14 @@ func TestGenerateExampleWithServer(t *testing.T) {
// NOTE: Currently, the server needs a config to know where to get load plugins,
// and how to handle targets/templates. This will be simplified in the future to
// decoupled the server from required a config altogether.
config.Targets["test"] = configurator.Target{
conf.Targets["test"] = configurator.Target{
TemplatePaths: []string{},
FilePaths: []string{},
}
// create new server, add test generator, and start in background
server := server.New(&config)
server.GeneratorParams.Generators = map[string]generator.Generator{
"test": &gen,
}
server := server.New(&conf)
generator.DefaultGenerators["test"] = &gen
go server.Serve()
// make request to server to generate a file
@ -390,10 +400,7 @@ func TestGenerateExampleWithServer(t *testing.T) {
//
// NOTE: we don't actually use the config in this plugin implementation,
// but we do check that a valid config was passed.
fileMap, err := gen.Generate(
&config,
generator.WithClient(client),
)
fileMap, err := gen.Generate(&conf, generator.Params{})
if err != nil {
t.Fatalf("failed to generate file: %v", err)
}