Compare commits

...

90 commits

Author SHA1 Message Date
8960c2478a
Merge branch 'major-rewrite' 2025-08-31 22:21:00 -06:00
277de43a02
feat: added Init() and Cleanup() in hooks 2025-08-31 22:04:02 -06:00
bdd85b01ff
feat: added cacerts and some tidying 2025-08-31 22:02:10 -06:00
2112e7eefd
chore: changed permission of bin/ helper script 2025-08-31 21:59:40 -06:00
eac73ada69
chore: removed TODO and update plugin struct 2025-08-31 21:59:14 -06:00
c799dc7838
chore: updated examples in README and cmd 2025-08-31 11:58:17 -06:00
c9f40e3857
chore: added server root section to README 2025-08-31 11:56:11 -06:00
8e1fa3d2ab
feat: updated cmd/pkg implementations and cleanup 2025-08-31 11:48:49 -06:00
cb3d4ce8db
feat: added tip about local plugins info 2025-08-31 11:03:25 -06:00
e115319913
feat: implemented local plugins info 2025-08-31 11:01:43 -06:00
fa8ef7ab4b
chore: fix typos in files 2025-08-31 00:21:19 -06:00
afc7db53e1
chore: updated README with upload examples 2025-08-31 00:20:23 -06:00
c495d10aad
refactor: change reference in goreleaser 2025-08-31 00:17:00 -06:00
13b02c03e8
feat: implemented upload cmd and pkg 2025-08-31 00:13:33 -06:00
fbed466c3d
refactor: updated cmd and pkg implementations 2025-08-30 23:30:46 -06:00
d88ab2c01f
feat: added compile-plugins.sh script 2025-08-30 23:21:17 -06:00
ebb95a29ec
refactor: renamed userdata plugin to mapper 2025-08-30 23:20:47 -06:00
5d350717f4
chore: updated references in Makefile 2025-08-30 23:19:45 -06:00
18edb93d2c
feat: added delete cmd 2025-08-30 23:19:18 -06:00
32d534bcea
chore: updated README 2025-08-30 23:18:47 -06:00
0349ecaf34
chore: added TODO in README 2025-08-30 00:31:45 -06:00
d191577ac9
chore: updated README export vars 2025-08-30 00:29:34 -06:00
4771bf45ac
refactor: added MAKESHIFT_LOG_LEVEL env var 2025-08-30 00:29:18 -06:00
7fa685e862
refactor: correct comment in README 2025-08-30 00:26:50 -06:00
dc8a9cff20
refactor: minor changes and fixes 2025-08-30 00:23:53 -06:00
b791b84890
chore: updated README examples 2025-08-30 00:22:58 -06:00
73498a08de
fix: issue with host not being set for plugins cmd 2025-08-30 00:13:05 -06:00
0f6f8957f6
refactor: updated cmd request and added plugin info 2025-08-30 00:04:17 -06:00
4d96010199
refactor: updated routes and handlers 2025-08-29 23:52:43 -06:00
947fbba854
feat: added plugin and profile list commands 2025-08-29 23:51:43 -06:00
94887aae9e
fix: panic with using init cmd 2025-08-29 22:39:49 -06:00
325f77b9d4
feat: added init cmd implementation 2025-08-29 22:33:07 -06:00
418889b17f
refactor: added error check for server.Init() 2025-08-29 22:32:37 -06:00
85e333289b
feat: added init cmd with example 2025-08-29 19:43:24 -06:00
e458783061
feat: added run example and args 2025-08-29 19:39:40 -06:00
a4d1de9a51
refactor: change MAKESHIFT_SERVER_ROOT to MAKESHIFT_ROOT 2025-08-29 19:39:09 -06:00
036bda61b9
feat: added format package for files 2025-08-29 19:27:26 -06:00
ac36201f07
feat: initial upload cmd implementation 2025-08-29 19:27:06 -06:00
e2b400fb12
refactor: added route impls and minor changes 2025-08-29 19:26:50 -06:00
dc6818e1e2
chore: updated go deps 2025-08-29 19:26:13 -06:00
08a9b9bdcf
refactor: removed unused code and added routes 2025-08-29 18:23:00 -06:00
b18746957d
refactor: removed unused code and fix typo 2025-08-29 17:58:16 -06:00
cdc441344f
chore: fix formatting in README 2025-08-29 17:40:50 -06:00
d1e7b275a6
chore: updated README with makeshift info 2025-08-29 17:24:44 -06:00
f917d2b6f8
fix: issue with plugins not downloading 2025-08-29 16:24:40 -06:00
135245ca9c
cleanup: removed unused code and formatting 2025-08-29 16:24:23 -06:00
df8730463e
cleanup: removed unused code/comments 2025-08-29 16:07:57 -06:00
3244a66f8e
fix: issue with logging not initializing correctly 2025-08-29 16:02:36 -06:00
e5c1b59bc1
feat: allow expanding and remove archive after download 2025-08-29 11:04:16 -06:00
c2d5be5eed
refactor: changed default help.txt j2 vars 2025-08-28 19:45:17 -06:00
f897bc3ca5
fix: issue with downloading without hooks 2025-08-28 19:43:53 -06:00
2536848541
refactor: changed how file/archives are downloaded and saved 2025-08-28 18:20:40 -06:00
98f9acad5d
feat: added downloading templated archives 2025-08-28 12:25:45 -06:00
1ebea8cb73
feat: added working rendering with plugins 2025-08-26 22:11:17 -06:00
fbdaf218eb
chore: added .gitignore 2025-08-24 20:44:52 -06:00
134a0dcac0
feat: added initial hurl tests 2025-08-24 20:42:27 -06:00
eb126d5350
feat: moved and updated archive utility 2025-08-24 20:41:34 -06:00
8b161135ff
feat: updated pkg implementations 2025-08-24 20:40:53 -06:00
86f37555b2
feat: added logging implementation 2025-08-24 20:40:24 -06:00
59a5225b28
feat: updated implementations for cmds 2025-08-24 20:39:39 -06:00
7a96bfd6c7
refactor: combined files defining models 2025-08-24 20:38:39 -06:00
2d6eb1d972
refactor: changed name from configurator to makeshift 2025-08-24 20:36:23 -06:00
0de7beefd0
refactor: removed unnecessary cmd commands 2025-08-24 20:35:36 -06:00
ea4819e97a
refactor: moved plugin files 2025-08-24 20:34:38 -06:00
c24bcf34d4
refactor: removed the example plugins from configurator 2025-08-24 20:33:41 -06:00
5767a8fd47
chore: moved archlinux pkgbuild script 2025-08-24 20:33:05 -06:00
a8e9ed95e6
chore: updated makefile 2025-08-24 20:32:31 -06:00
68d905067f
chore: updated readme 2025-08-24 20:32:12 -06:00
d7a0ddc1c2
chore: updated changelog 2025-08-24 20:31:51 -06:00
a54b39b296
chore: updated go deps 2025-08-24 20:31:10 -06:00
2da5ca3702
feat: added jinja2 and userdata plugins 2025-08-19 21:33:58 -06:00
5c4bbe0b58
feat: added KV storage interface 2025-08-19 21:33:28 -06:00
4d33b12fe0
feat: added 'profile' and 'plugins' flags 2025-08-19 21:32:45 -06:00
97fa0a1062
chore: updated go deps 2025-08-19 21:32:13 -06:00
d56a9e452f
feat: updated server implementation 2025-08-18 11:09:51 -06:00
0d27f07a8b
feat: updated client implementation 2025-08-18 11:04:44 -06:00
fa949baafd
refactor: updated plugin interface 2025-08-18 11:03:54 -06:00
fe67674829
fix: issue creating archive that was a directory 2025-08-18 11:03:19 -06:00
920703fc0e
feat: added skeleton implementation for run and profiles cmd 2025-08-18 11:02:33 -06:00
7e9e186138
chore: updated go deps 2025-08-18 11:01:32 -06:00
419e9781bf
feat: added root, download, list, and serve cmd implementations 2025-08-18 11:01:19 -06:00
1f5775196d
chore: updated license file 2025-08-18 10:59:34 -06:00
05a4913e70
Merge branch 'major-rewrite' of https://git.towk2.me/towk/configurator-ng into major-rewrite 2025-08-14 16:53:48 -06:00
a8c16ed715
chore: added directories and update go deps 2025-08-14 07:41:47 -06:00
1413312893
refactor: updated cmd command funcs 2025-08-14 07:41:09 -06:00
a1a9c6407f
refactor: added more implementation details 2025-08-14 07:40:30 -06:00
72be62c78e
refactor: more implementation to refactor 2025-08-13 22:52:52 -06:00
50e6b53091
refactor: more implementation to refactor and deleted files 2025-08-04 22:32:41 -06:00
ba684bd149
refactor: more implementation to refactor 2025-08-04 15:58:02 -06:00
bfd83f35a3
refactor: initial commit for major rewrite 2025-08-03 20:25:18 -06:00
72 changed files with 4269 additions and 2901 deletions

5
.gitignore vendored
View file

@ -1,4 +1,5 @@
**configurator
**makeshift
**.yaml
**.yml
**.so
@ -6,3 +7,7 @@
**.ignore
**.tar.gz
dist/
tests/data
tests/downloads
tests/profiles
tests/plugins

View file

@ -5,7 +5,7 @@ before:
- go mod download
- make plugins
builds:
- id: "configurator"
- id: "makeshift"
goos:
- linux
goarch:
@ -30,10 +30,10 @@ archives:
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 }}
- git.towk2.me/towk/{{.ProjectName}}:latest
- git.towk2.me/towk/{{.ProjectName}}:{{ .Tag }}
- git.towk2.me/towk/{{.ProjectName}}:v{{ .Major }}
- git.towk2.me/towk/{{.ProjectName}}:v{{ .Major }}.{{ .Minor }}
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"

View file

@ -5,6 +5,7 @@ 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
- Initial prerelease of makeshift

View file

@ -1,6 +1,6 @@
MIT License
Copyright © 2024 Triad National Security, LLC. This program was produced under U.S. Government contract 89233218CNA000001 for Los Alamos National Laboratory (LANL), which is operated by Triad National Security, LLC for the U.S. Department of Energy/National Nuclear Security Administration.
Copyright © 2025 David J. Allen
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),

View file

@ -1,7 +1,7 @@
# Unless set otherwise, the container runtime is Docker
DOCKER ?= docker
prog ?= configurator
prog ?= makeshift
git_tag := $(shell git describe --abbrev=0 --tags --always)
sources := main.go $(wildcard cmd/*.go)
plugin_source_prefix := pkg/generator/plugins
@ -36,18 +36,18 @@ container-testing: binaries
plugins: $(plugin_binaries)
# how to make each plugin
lib/%.so: pkg/generator/plugins/%/*.go
lib/%.so: pkg/plugins/%/*.go
mkdir -p lib
go build -buildmode=plugin -o $@ $<
docs:
go doc github.com/OpenCHAMI/cmd
go doc github.com/OpenCHAMI/pkg/configurator
go doc git.towk2.me/towk/makeshift/cmd
go doc git.towk2.me/towk/makeshift/pkg/${prog}
# remove executable and all built plugins
.PHONY: clean
clean:
rm -f configurator
rm -f ${prog}
rm -f lib/*
# run all of the unit tests

358
README.md
View file

@ -1,219 +1,241 @@
# OpenCHAMI Configurator
# << Makeshift >>
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.
The `makeshift` tool is a service that serves files and CLI that downloads them with a couple of handy features baked-in. Although the CLI and server component function more like a glorified FTP, the power of this tool comes from the plugin system. For example, the file cobbler is built to run external plugins for more advanced processing files before serving them (e.g. fetching from a data source, rendering Jinja 2 templates, etc.).
## Building and Usage
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:
## Building and Go!
The `makeshift` tool is built using standard `go` build tools. To get started, clone the project, download the dependencies, and build the project:
```bash
git clone https://github.com/OpenCHAMI/configurator.git
git clone https://git.towk2.me/towk/makeshift.git
go mod tidy
go build --tags all # equivalent to `go build --tags client,server``
go build
```
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.
> [!NOTE]
> The project does not current separate the client, server, and plugin components using build tags, but will eventually. This will allow users to only compile and distribute specific parts of the tool with limited functionality.
### Running Configurator with CLI
After you build the program, run the following command to use the tool:
## Basic Examples
Here are some of the common commands you may want to try right off the bat (aside from `makeshift help` of course). The commands below that do not include the `--host`, `--path`, or `--root` flags are set using the environment variables.
```bash
export ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIs...
./configurator generate --config config.yaml --target coredhcp -o coredhcp.conf --cacert ochami.pem
export MAKESHIFT_HOST=localhost
export MAKESHIFT_PATH=/test
export MAKESHIFT_ROOT=./test
export MAKESHIFT_LOG_FILE=logs/makeshift.log
export MAKESHIFT_LOG_LEVEL=debug
```
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).
Start the server. The `--init` flag with create the default files and directory to get started at the `--root` path.
In other words, there should be an entry in the config file that looks like this:
```bash
makeshift serve --root $HOME/apps/makeshift/server --init
```
```yaml
...
targets:
coredhcp:
plugin: "lib/coredhcp.so" # optional, if we want to use an external plugin instead
templates:
- templates/coredhcp.j2
...
From here, you might want to see what files are available by default.
```bash
# list the files in the root directory
makeshift list
# list files store in the template directory with a specified host
makeshift list --host http://localhost:5050 --path templates
# list all available plugins
makeshift list plugins
# list specific plugin (same as 'makeshift plugins info jinja2')
makeshift list plugins jinja2
# list all available profiles
makeshift list profiles
# list specific profile information
makeshift list profiles default
```
Then, we can start downloading some files or directories (as archives).
```bash
# download all data (notice --host and --port are not set here)
makeshift download
# download the 'help.txt' file without processing (i.e. using plugins)
makeshift download --host http://localhost:5050 --path help.txt
# download files with rendering using Jinja 2 plugin and default profile
makeshift download -p help.txt --plugins jinja2 --profile default
# download directory with rendering using plugins to fetch data and render
# using a custom 'compute' profile
makeshift download -p templates --plugins smd,jinja2 --profile compute
# do everything in the above example but extract and remove archive
makeshift download -p templates --plugins smd,jinja2 --profile compute -xr
# download a raw plugin
makeshift download plugin jinja2
# download a profile
makeshift download profile default
```
> [!NOTE]
> The `configurator` tool requires a valid access token when making requests to an instance of SMD that has protected routes.
> Plugins are ran in order specified with the `--plugins` flag, which means if you're creating a plugin to write to a data store and then read in a subsequent plugin, the order specified with the CLI matters!
### Running Configurator as a Service
The tool can also run as a service to generate files for clients:
(WIP) Files, directories, profiles, and plugins will eventually be able to be uploaded to the server.
```bash
export CONFIGURATOR_JWKS_URL="http://my.openchami.cluster:8443/key"
./configurator serve --config config.yaml
```
# upload a single file in root directory
makeshift upload -d @compute-base.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:
# upload a directory (not working yet...)
makeshift upload -d @setup/
```bash
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 coredhcp --host http://127.0.0.1:3334 --cacert ochami.pem
```
# upload an archive (extracted and saved on server - not working yet...)
makeshift upload -d @setup.tar.gz -t archive
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.
# upload a new profile
makeshift upload profile -d @compute.json kubernetes.json
### Docker
# upload a new profile with a specific path
makeshift upload profile -d @kubernetes.json
makeshift upload profile -d '{"id": "custom", "data": {}}' kubernetes.json
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 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
// 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) (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 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 {
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
g.PluginInfo := LoadFromFile("path/to/plugin/info.json")
return g.PluginInfo["name"]
}
func (g *MyGenerator) GetVersion() string {
return g.PluginInfo["version"] // "v1.0.0"
}
func (g *MyGenerator) GetDescription() string {
return g.PluginInfo["description"] // "This is an example plugin."
}
func (g *MyGenerator) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
// do config generation stuff here...
var (
params = generator.GetParams(opts...)
client = generator.GetClient(params)
output = ""
)
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 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
# upload a new plugin
makeshift upload plugin -d @slurm.so
makeshift upload plugin slurm.so
```
> [!NOTE]
> The keys in `generator.ApplyTemplate` must not contain illegal characters such as a `-` or else the templates will not apply correctly.
> Although every command has a `curl` equivalent, it is better to use the CLI since it has other features such as extracting and remove archives after downloading and saving archives as files automatically.
Finally, build the plugin and put it somewhere specified by `plugins` in your config. Make sure that the package is `main` before building.
## Server Root Structure
The `makeshift` server serves files at the specified `--root` path (also set with `MAKESHIFT_ROOT` environment variable). The directory structure looks like the following by default with initializing with `makeshift init $MAKESHIFT_ROOT`.
```bash
go build -buildmode=plugin -o lib/mygenerator.so path/to/mygenerator.go
├── data
├── plugins
└── profiles
```
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.
Each directory holds specific files for different purposes:
- `data` - Stores any and all miscellaenous files and directories.
- `plugins` - Stores plugins defined in the ["Creating Plugins"](#creating-plugins) section.
- `profiles` - Stores profiles in JSON format as defined in the ["Creating Profiles"](#creating-profiles) section.
## Creating Plugins
The `makeshift` tool defines a plugin as an interface that can be implemented and compiled.
```go
type Plugin interface {
Name() string
Version() string
Description() string
Metadata() Metadata
Init() error
Run(data storage.KVStore, args []string) error
Cleanup() error
}
```
Plugins can *literally* contain whatever you want and is written in Go. Here is a simple example implementation to demonstrate how that is done which we will save at `src/example.go`.
```go
type Example struct{}
func (p *Example) Name() string { return "example" }
func (p *Example) Version() string { return "v0.0.1-alpha" }
func (p *Example) Description() string { return "An example plugin" }
func (p *Example) Metadata() map[string]string {
return makeshift.Metadata{
"author": map[string]any{
"name": "John Smith",
"email": "john.smith@example",
"links": []string{
"https://example.com",
},
},
}
}
func (p *Example) Init() error {
// Initialize the plugin if necessary.
return nil
}
func (p *Example) Run(data storage.KVStore, args []string) error {
// Plugins can read and write to a data stores passed in.
// See the 'jinja2' plugin for reading and 'smd' plugin for writing.
return nil
}
func (p *Example) Clean() error {
// Clean up resources if necessary.
return nil
}
// This MUST be included to find the symbol in the main driver executable.
var Makeshift Example
```
Then, we can use the built-in `makeshift plugins compile` command to compile it.
```bash
makeshift plugins compile src/example.go -o $MAKESHIFT_ROOT/plugins/example.so
```
> [!TIP]
> See the `examples/test.go` file for a plugin and template example.
> Make sure you move all of your plugins to `$MAKESHIFT_ROOT/plugins` to use them and should have an `*.so` name for lookup. For example, to use a custom plugin with `makeshift download -p templates/hosts.j2 --plugins my-plugin`, there has to a plugin `$MAKESHIFT_ROOT/plugins/my-plugin.so`.
## Configuration
## Creating Profiles
Here is an example config file to start using configurator:
On the other hand, profiles are simply objects that contain data used to populate data stores. The `makeshift` tool does not currently use all fields of a profile which will likely be removed in the near future.
```yaml
server: # Server-related parameters when using as service
host: 127.0.0.1
port: 3334
jwks: # Set the JWKS uri for protected routes
uri: ""
retries: 5
smd: # SMD-related parameters
host: http://127.0.0.1:27779
plugins: # path to plugin directories
- "lib/"
targets: # targets to call with --target flag
coredhcp:
templates:
- templates/coredhcp.j2
files: # files to be copied without templating
- extra/nodes.conf
targets: # additional targets to run (does not run recursively)
- dnsmasq
```go
type Profile struct {
ID string `json:"id"` // profile ID
Description string `json:"description,omitempty"` // profile description
Tags []string `json:"tags,omitempty"` // tags used for filtering (not implemented yet)
Data map[string]any `json:"data,omitempty"` // include render data
}
```
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.
Profiles can be created using JSON and only require an `id` with optional `data`. See the example in `$MAKESHIFT_ROOT/profiles/default.json`.
## Running the Tests
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
```json
{
"id": "default",
"description": "Makeshift default profile",
"data": {
"host": "localhost",
"path": "/test",
"server_root": "./test"
}
}
```
## Known Issues
> [!TIP]
> Make sure that you store your custom profiles in `$MAKESHIFT_ROOT/profiles` and that you set the name you want to use for lookup with a `*.json` extension (e.g. `compute.json`).
- Adds a new `OAuthClient` with every token request
- Plugins are being loaded each time a file is generated
## TODO: Missing Features
## TODO
There are some features still missing that will be added later.
- Add group functionality to create by files by groups
- Extend SMD client functionality (or make extensible?)
- Handle authentication with `OAuthClient`'s correctly
1. Running `makeshift` locally with profiles and plugins
2. Plugin to add user data for one-time use without creating a profile
3. Optionally build plugins directly into the main driver
4. Protected routes that require authentication
5. Configuration file for persistent runs
6. `Dockerfile` and `docker-compose.yml` files to build containers

26
bin/compile-plugins.sh Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin bash
function compile_default_plugins() {
makeshift_exe=./makeshift
go_exe=go
# make sure go build tools are installed
if command -v $go_exe >/dev/null 2>&1; then
# make sure that MAKESHIFT_ROOT is set
if [[ ! -v MAKESHIFT_ROOT ]]; then
# Compile the default external plugins
go build
$makeshift_exe compile pkg/plugins/jinja2/jinja2.go -o $MAKESHIFT_ROOT/plugins/jinja2.go
$makeshift_exe compile pkg/plugins/smd/smd.go -o $MAKESHIFT_ROOT/plugins/smd.so
$makeshift_exe compile pkg/plugins/userdata/userdata.go -o $MAKESHIFT_ROOT/plugins/userdata.go
else
echo "requires MAKESHIFT_ROOT to be set"
fi
else
echo "Go build tools must be installed"
fi
}
compile_default_plugins

View file

@ -1,15 +1,15 @@
# Maintainer: David J. Allen <allend@lanl.gov>
pkgname=configurator
# Maintainer: David J. Allen <davidallendj@gmail.com>
pkgname=makeshift
pkgver=v0.1.0alpha
pkgrel=1
pkgdesc="An extensible tool to dynamically generate config files from SMD with Jinja 2 templating support."
pkgdesc="Extensible file cobbler"
arch=("x86_64")
url="https://github.com/OpenCHAMI/configurator"
url="https://git.towk2.me/towk/makeshift"
license=('MIT')
groups=("openchami")
provides=('configurator')
conflicts=('configurator')
https://github.com/OpenCHAMI/configurator/releases/download/v0.1.0-alpha/configurator
# groups=("towk")
provides=('makeshift')
conflicts=('makeshift')
# https://git.towk2.me/towk/makeshift/releases/download/v0.1.0-alpha/makeshift
source_x86_64=(
"${url}/releases/download/v0.1.0-alpha/${pkgname}.tar.gz"
)
@ -27,7 +27,7 @@ package() {
# 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 -m755 makeshift "${pkgdir}/usr/bin/makeshift"
# install plugins to /usr/lib
install -m755 *.so "${pkgdir}/usr/lib/${pkgname}"

View file

@ -1,32 +0,0 @@
package cmd
import (
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Create a new default config file",
Run: func(cmd *cobra.Command, args []string) {
// create a new config at all args (paths)
//
// TODO: change this to only take a single arg since more
// than one arg is *maybe* a mistake
for _, path := range args {
// check and make sure something doesn't exist first
if exists, err := util.PathExists(path); exists || err != nil {
log.Error().Err(err).Msg("file or directory exists")
continue
}
config.SaveDefault(path)
}
},
}
func init() {
rootCmd.AddCommand(configCmd)
}

155
cmd/delete.go Normal file
View file

@ -0,0 +1,155 @@
package cmd
import (
"fmt"
"net/http"
"git.towk2.me/towk/makeshift/pkg/client"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var deleteCmd = &cobra.Command{
Use: "delete",
Example: `
# set up environment
export MAKESHIFT_HOST=http://localhost:5050
export MAKESHIFT_PATH=test
# delete a file or directory (cannot delete root)
makeshift delete -p help.txt
makeshift delete --host http://localhost:5555 --path templates
`,
Short: "Delete files and directories",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
setenv(cmd, "host", "MAKESHIFT_HOST")
setenv(cmd, "path", "MAKESHIFT_PATH")
setenv(cmd, "cacert", "MAKESHIFT_CACERT")
},
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
paths, _ = cmd.Flags().GetStringSlice("path")
cacertPath, _ = cmd.Flags().GetString("cacert")
c = client.New(host)
res *http.Response
query string
err error
)
log.Debug().
Str("host", host).
Str("cacert", cacertPath).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
for _, path := range paths {
if path == "" {
log.Warn().Msg("skipping empty path")
continue
}
query = fmt.Sprintf("/delete/%s?", path)
res, _, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodDelete,
})
handleResponseError(res, host, query, err)
}
},
}
var deleteProfilesCmd = &cobra.Command{
Use: "profiles",
Example: `
# delete profile(s) by its ID
makeshift delete profiles kubernetes slurm compute
`,
Args: cobra.MinimumNArgs(1),
Short: "Delete profile(s)",
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
cacertPath, _ = cmd.Flags().GetString("cacert")
c = client.New(host)
res *http.Response
query string
err error
)
log.Debug().
Str("host", host).
Str("cacert", cacertPath).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
for _, profileID := range args {
if profileID == "default" {
log.Warn().Msg("cannot delete the default profile")
continue
}
query = fmt.Sprintf("/profiles/%s", profileID)
res, _, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodDelete,
})
handleResponseError(res, host, query, err)
}
},
}
var deletePluginsCmd = &cobra.Command{
Use: "plugins",
Example: `
# delete plugin(s) by name
makeshift delete plugins weather slurm user
`,
Args: cobra.MinimumNArgs(1),
Short: "Delete plugin(s)",
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
cacertPath, _ = cmd.Flags().GetString("cacert")
c = client.New(host)
res *http.Response
query string
err error
)
log.Debug().
Str("host", host).
Str("cacert", cacertPath).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
for _, pluginName := range args {
query = fmt.Sprintf("/plugins/%s", pluginName)
res, _, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodDelete,
})
handleResponseError(res, host, query, err)
}
},
}
func init() {
deleteCmd.PersistentFlags().String("host", "http://localhost:5050", "Set the makeshift server host (can be set with MAKESHIFT_HOST)")
deleteCmd.PersistentFlags().String("cacert", "", "Set the CA certificate path to load")
deleteCmd.Flags().StringSliceP("path", "p", []string{}, "Set the paths to delete files and directories")
deleteCmd.AddCommand(deleteProfilesCmd, deletePluginsCmd)
rootCmd.AddCommand(deleteCmd)
}

305
cmd/download.go Normal file
View file

@ -0,0 +1,305 @@
package cmd
import (
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"git.towk2.me/towk/makeshift/internal/archive"
"git.towk2.me/towk/makeshift/pkg/client"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var downloadCmd = cobra.Command{
Use: "download",
Example: `
# set up environment
export MAKESHIFT_HOST=http://localhost:5050
export MAKESHIFT_PATH=test
# download a file or directory (as archive)
makeshift download
makeshift download --host http://localhost:5050.com --path test
# download a file or directory and run plugins with profile data
makeshift download --plugins smd,jinja2 --profile compute
curl $MAKESHIFT_HOST/download/test?plugins=smd,jinja2&profile=test
# download directory and extract it's contents automatically
# then, remove the downloaded archive
makeshift download -xr
`,
Short: "Download and modify files with plugins",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
setenv(cmd, "host", "MAKESHIFT_HOST")
setenv(cmd, "path", "MAKESHIFT_PATH")
setenv(cmd, "cacert", "MAKESHIFT_CACERT")
},
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
path, _ = cmd.Flags().GetString("path")
outputPath, _ = cmd.Flags().GetString("output")
cacertPath, _ = cmd.Flags().GetString("cacert")
pluginNames, _ = cmd.Flags().GetStringSlice("plugins")
profileIDs, _ = cmd.Flags().GetStringSlice("profiles")
extract, _ = cmd.Flags().GetBool("extract")
removeArchive, _ = cmd.Flags().GetBool("remove-archive")
c = client.New(host)
res *http.Response
query string
body []byte
err error
)
query = fmt.Sprintf("/download/%s?", path)
if len(pluginNames) > 0 {
query += "plugins=" + url.QueryEscape(strings.Join(pluginNames, ","))
}
if len(profileIDs) > 0 {
query += "&profiles=" + url.QueryEscape(strings.Join(profileIDs, ","))
}
log.Debug().
Str("host", host).
Str("path", path).
Str("query", query).
Str("output", outputPath).
Strs("profiles", profileIDs).
Strs("plugins", pluginNames).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
res, body, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodGet,
})
if err != nil {
log.Error().Err(err).
Str("host", host).
Str("path", path).
Str("output", outputPath).
Msg("failed to make request")
os.Exit(1)
}
if res.StatusCode != http.StatusOK {
log.Error().
Any("status", map[string]any{
"code": res.StatusCode,
"message": res.Status,
}).
Str("host", host).
Str("path", path).
Str("output", outputPath).
Msg("response returned bad status")
os.Exit(1)
}
// determine if output path is an archive or file
switch res.Header.Get("FILETYPE") {
case "archive":
// write archive to disk with or without '-o' specified
if outputPath == "" {
outputPath = fmt.Sprintf("%s.tar.gz", path)
writeFiles(outputPath, body)
log.Debug().Str("path", outputPath).Msg("wrote archive to pre-determined path")
} else {
writeFiles(outputPath, body)
log.Debug().Str("path", outputPath).Msg("wrote archive to specified path")
}
// extract files if '-x' flag is passed
if extract {
var (
dir = filepath.Dir(outputPath)
base = strings.TrimSuffix(filepath.Base(outputPath), ".tar.gz")
)
err = archive.Expand(outputPath, fmt.Sprintf("%s/%s", dir, base))
if err != nil {
log.Error().Err(err).
Str("path", outputPath).
Msg("failed to expand archive")
os.Exit(1)
}
}
// optionally, remove archive if '-r' flag is passed
// NOTE: this can only be used if `-x` flag is set
if removeArchive {
if !extract {
log.Warn().Msg("requires '-x/--extract' flag to be set to 'true'")
} else {
err = os.Remove(outputPath)
if err != nil {
log.Error().Err(err).
Str("path", outputPath).
Msg("failed to remove archive")
}
}
}
case "file":
// write to file if '-o' specified otherwise stdout
if outputPath != "" {
writeFiles(outputPath, body)
log.Debug().Str("path", outputPath).Msg("wrote file to specified path")
} else {
fmt.Println(string(body))
}
}
},
}
var downloadProfileCmd = &cobra.Command{
Use: "profile",
Example: `
// download a profile
makeshift download profile default
`,
Args: cobra.ExactArgs(1),
Short: "Download a profile",
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
outputPath, _ = cmd.Flags().GetString("output")
cacertPath, _ = cmd.Flags().GetString("cacert")
c = client.New(host)
res *http.Response
body []byte
query string
err error
)
log.Debug().
Str("host", host).
Str("output", outputPath).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
for _, profileID := range args {
query = fmt.Sprintf("/profiles/%s", profileID)
res, body, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodGet,
})
if err != nil {
log.Error().Err(err).
Str("host", host).
Msg("failed to make request")
os.Exit(1)
}
if res.StatusCode != http.StatusOK {
log.Error().
Any("status", map[string]any{
"code": res.StatusCode,
"message": res.Status,
}).
Str("host", host).
Msg("response returned bad status")
os.Exit(1)
}
if outputPath != "" {
writeFiles(outputPath, body)
} else {
fmt.Println(string(body))
}
}
},
}
var downloadPluginCmd = &cobra.Command{
Use: "plugin",
Example: `
// download a plugin
makeshift download plugin smd jinja2
`,
Args: cobra.ExactArgs(1),
Short: "Download a raw plugin",
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
outputPath, _ = cmd.Flags().GetString("output")
cacertPath, _ = cmd.Flags().GetString("cacert")
c = client.New(host)
res *http.Response
query string
body []byte
err error
)
log.Debug().
Str("host", host).
Str("output", outputPath).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
for _, pluginName := range args {
query = fmt.Sprintf("/plugins/%s/raw", pluginName)
res, body, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodGet,
})
if err != nil {
log.Error().Err(err).
Str("host", host).
Str("query", query).
Msg("failed to make request")
os.Exit(1)
}
if res.StatusCode != http.StatusOK {
log.Error().
Any("status", map[string]any{
"code": res.StatusCode,
"message": res.Status,
}).
Str("host", host).
Msg("response returned bad status")
os.Exit(1)
}
if outputPath != "" {
writeFiles(outputPath, body)
} else {
writeFiles(fmt.Sprintf("%s.so", pluginName), body)
}
}
},
}
func init() {
downloadCmd.PersistentFlags().String("host", "http://localhost:5050", "Set the makeshift remote host (can be set with MAKESHIFT_HOST)")
downloadCmd.PersistentFlags().StringP("output", "o", "", "Set the output path to write files")
downloadCmd.PersistentFlags().String("cacert", "", "Set the CA certificate path to load")
downloadCmd.Flags().StringP("path", "p", ".", "Set the path to list files (can be set with MAKESHIFT_PATH)")
downloadCmd.Flags().StringSlice("profiles", []string{}, "Set the profile(s) to use to populate data store")
downloadCmd.Flags().StringSlice("plugins", []string{}, "Set the plugin(s) to run before downloading files")
downloadCmd.Flags().BoolP("extract", "x", false, "Set whether to extract archive locally after downloading")
downloadCmd.Flags().BoolP("remove-archive", "r", false, "Set whether to remove the archive after extracting (used with '--extract' flag)")
downloadCmd.AddCommand(downloadProfileCmd, downloadPluginCmd)
rootCmd.AddCommand(&downloadCmd)
}
// helper to write downloaded files
func writeFiles(path string, body []byte) {
var err = os.WriteFile(path, body, 0o755)
if err != nil {
log.Error().Err(err).Msg("failed to write file(s) from download")
os.Exit(1)
}
}

View file

@ -1,77 +0,0 @@
//go:build client || all
// +build client all
package cmd
import (
"fmt"
"net/http"
"os"
"github.com/OpenCHAMI/configurator/pkg/util"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var fetchCmd = &cobra.Command{
Use: "fetch",
Short: "Fetch a config file from a remote instance of configurator",
Long: "This command is simplified to make a HTTP request to the a configurator service.",
Run: func(cmd *cobra.Command, args []string) {
// make sure a host is set
if remoteHost == "" {
log.Error().Msg("no '--host' argument set")
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 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 != "" {
conf.AccessToken = accessToken
} else {
// TODO: try and fetch token first if it is needed
if verbose {
log.Warn().Msg("No token found. Attempting to generate config without one...")
}
}
}
// add the "Authorization" header if an access token is supplied
headers := map[string]string{}
if accessToken != "" {
headers["Authorization"] = "Bearer " + accessToken
}
for _, target := range targets {
// make a request for each 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 fetch files")
return
}
// handle getting other error codes other than a 200
if res != nil {
// 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 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")
rootCmd.AddCommand(fetchCmd)
}

View file

@ -1,212 +0,0 @@
//go:build client || all
// +build client all
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"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
templatePaths []string
pluginPath string
useCompression bool
)
var generateCmd = &cobra.Command{
Use: "generate",
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 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 != "" {
conf.AccessToken = accessToken
} else {
// TODO: try and fetch token first if it is needed
if verbose {
log.Warn().Msg("No token found. Attempting to generate conf without one...\n")
}
}
}
// use cert path from cobra if empty
if conf.CertPath == "" {
conf.CertPath = cacertPath
}
// show conf as JSON and generators if verbose
if verbose {
b, err := json.MarshalIndent(conf, "", " ")
if err != nil {
log.Error().Err(err).Msg("failed to marshal config")
}
// print the config file as JSON
fmt.Printf("%v\n", string(b))
}
// 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))
}
},
}
// Generate files by supplying a list of targets as string values. Currently,
// targets are defined statically in a config file. Targets are ran recursively
// if more targets are nested in a defined target, but will not run additional
// child targets if it is the same as the parent.
//
// NOTE: This may be changed in the future how this is done.
func RunTargets(conf *config.Config, args []string, targets ...string) {
// generate config with each supplied target
for _, target := range targets {
outputBytes, err := generator.GenerateWithTarget(conf, target)
if err != nil {
log.Error().Err(err).Str("target", target).Msg("failed to generate config")
os.Exit(1)
}
// 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))
// remove any targets that are the same as current to prevent infinite loop
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(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 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)
}

42
cmd/init.go Normal file
View file

@ -0,0 +1,42 @@
package cmd
import (
"git.towk2.me/towk/makeshift/pkg/service"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var initCmd = &cobra.Command{
Use: "init",
Example: `
# create default files and directories at specified root path
# (must be set with positional argument)
makeshift init $MAKESHIFT_ROOT
`,
Args: cobra.ExactArgs(1),
Short: "Initialize directory with default files",
Run: func(cmd *cobra.Command, args []string) {
var (
server *service.Service
err error
)
// create the server root files and directories
server = service.New()
server.RootPath = args[0]
err = server.Init()
if err != nil {
log.Error().Err(err).
Str("root", server.RootPath).
Msg("failed to initialize server root")
return
}
log.Debug().
Str("root", server.RootPath).
Msg("initialize makeshift files at root path")
},
}
func init() {
rootCmd.AddCommand(initCmd)
}

View file

@ -1,75 +0,0 @@
package cmd
import (
"fmt"
"io/fs"
"path/filepath"
"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"
)
var (
byTarget bool
)
var inspectCmd = &cobra.Command{
Use: "inspect",
Short: "Inspect generator plugin information",
Long: "The 'inspect' sub-command takes a list of directories and prints all found plugin information.",
Run: func(cmd *cobra.Command, args []string) {
// set up table formatter
table.DefaultHeaderFormatter = func(format string, vals ...interface{}) string {
return strings.ToUpper(fmt.Sprintf(format, vals...))
}
// 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 paths {
err := filepath.Walk(path, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
gen, err := generator.LoadPlugin(path)
if err != nil {
return err
}
generators[gen.GetName()] = gen
return nil
})
if err != nil {
log.Error().Err(err).Msg("failed to walk directory")
continue
}
}
// print all generator plugin information found
tbl := table.New("Name", "Version", "Description")
for _, g := range generators {
tbl.AddRow(g.GetName(), g.GetVersion(), g.GetDescription())
}
if len(generators) > 0 {
tbl.Print()
}
},
}
func init() {
inspectCmd.Flags().BoolVar(&byTarget, "by-target", false, "set whether to ")
rootCmd.AddCommand(inspectCmd)
}

216
cmd/list.go Normal file
View file

@ -0,0 +1,216 @@
package cmd
import (
"encoding/json"
"fmt"
"net/http"
"os"
makeshift "git.towk2.me/towk/makeshift/pkg"
"git.towk2.me/towk/makeshift/pkg/client"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var listCmd = &cobra.Command{
Use: "list",
Example: `
# list files in a remote data directory
configurator list --path test
configurator list --host http://localhost:5050 --path test
# list files using 'curl'
curl http://localhost:5050/list/test
`,
Args: cobra.NoArgs,
Short: "List all files in a remote data directory",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
setenv(cmd, "host", "MAKESHIFT_HOST")
setenv(cmd, "path", "MAKESHIFT_PATH")
setenv(cmd, "cacert", "MAKESHIFT_CACERT")
},
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
path, _ = cmd.Flags().GetString("path")
cacertPath, _ = cmd.Flags().GetString("cacert")
c = client.New(host)
body []byte
output []string
err error
)
log.Debug().
Str("host", host).
Str("path", path).
Str("cacert", cacertPath).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
// make request to /list endpoint
_, body, err = c.MakeRequest(client.HTTPEnvelope{
Path: fmt.Sprintf("/list/%s", path),
Method: http.MethodGet,
})
if err != nil {
log.Error().Err(err).
Str("host", host).
Str("path", path).
Msg("failed to make request")
os.Exit(1)
}
err = json.Unmarshal(body, &output)
if err != nil {
log.Error().Err(err).Msg("failed to unmarshal response body")
os.Exit(1)
}
// show the list of files and directories
log.Info().Strs("output", output).Send()
},
}
var listPluginsCmd = &cobra.Command{
Use: "plugins",
Example: `
# show all plugins
makeshift list plugins
# show details for specific plugins
makeshift list plugins smd jinja2
`,
Short: "Show plugins information",
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
cacertPath, _ = cmd.Flags().GetString("cacert")
c = client.New(host)
res *http.Response
query string
plugins []string
body []byte
err error
)
log.Debug().
Str("host", host).
Str("cacert", cacertPath).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
if len(args) == 0 {
// make request to /list endpoint
res, body, err = c.MakeRequest(client.HTTPEnvelope{
Path: "/plugins",
Method: http.MethodGet,
})
handleResponseError(res, host, "/plugins", err)
err = json.Unmarshal(body, &plugins)
if err != nil {
log.Error().Err(err).
Msg("failed to unmarshal plugins")
return
}
} else {
for _, pluginName := range args {
// make request to /list endpoint
query = fmt.Sprintf("/plugins/%s/info", pluginName)
res, body, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodGet,
})
handleResponseError(res, host, query, err)
plugins = append(plugins, string(body))
}
}
log.Info().Strs("plugins", plugins).Send()
},
}
var listProfilesCmd = &cobra.Command{
Use: "profiles",
Example: `
# list all profiles
makeshift list profiles
# live individual profiles
makeshift list profiles default custom
`,
Short: "Show all available profiles",
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
cacertPath, _ = cmd.Flags().GetString("cacert")
c = client.New(host)
res *http.Response
profiles []makeshift.Profile
body []byte
query string
err error
)
log.Debug().
Str("host", host).
Str("cacert", cacertPath).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
if len(args) == 0 {
// make request to /list endpoint
res, body, err = c.MakeRequest(client.HTTPEnvelope{
Path: "/profiles",
Method: http.MethodGet,
})
handleResponseError(res, host, "/profiles", err)
err = json.Unmarshal(body, &profiles)
if err != nil {
log.Error().Err(err).
Msg("failed to unmarshal plugins")
return
}
} else {
for _, profileID := range args {
// make request to /list endpoint
query = fmt.Sprintf("/profiles/%s", profileID)
res, body, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodGet,
})
handleResponseError(res, host, query, err)
var profile makeshift.Profile
err = json.Unmarshal(body, &profile)
if err != nil {
log.Error().Err(err).
Msg("failed to unmarshal plugin")
continue
}
profiles = append(profiles, profile)
}
}
log.Info().Any("plugins", profiles).Send()
},
}
func init() {
listCmd.PersistentFlags().String("host", "http://localhost:5050", "Set the configurator remote host (can be set with MAKESHIFT_HOST)")
listCmd.PersistentFlags().String("cacert", "", "Set the CA certificate path to load")
listCmd.Flags().StringP("path", "p", ".", "Set the path to list files (can be set with MAKESHIFT_PATH)")
listCmd.AddCommand(listPluginsCmd, listProfilesCmd)
rootCmd.AddCommand(listCmd)
}

246
cmd/plugins.go Normal file
View file

@ -0,0 +1,246 @@
package cmd
import (
"fmt"
"io/fs"
"net/http"
"os"
"os/exec"
"path/filepath"
makeshift "git.towk2.me/towk/makeshift/pkg"
"git.towk2.me/towk/makeshift/pkg/client"
"git.towk2.me/towk/makeshift/pkg/service"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var pluginsCmd = &cobra.Command{
Use: "plugins",
Short: "Manage, inspect, and compile plugins (requires Go build tools)",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
setenv(cmd, "host", "MAKESHIFT_HOST")
},
}
var pluginsCompileCmd = &cobra.Command{
Use: "compile",
Example: `
# compile plugin using Go build tools
go build -buildmode=plugin -o lib/myplugin.so src/plugins/myplugin.go
# try to compile all plugins in current directory
cd src/plugins
makeshift plugin compile
# try to compile all plugins in specified directory
makeshift plugin compile src/plugins
# compile 'src/plugins/myplugin.go' and save to 'lib/myplugin.so'
makeshift plugin compile src/plugins/myplugin.go -o lib/myplugin.so
`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var (
outputPath, _ = cmd.Flags().GetString("output")
output []byte
fileInfo os.FileInfo
err error
)
// make the directory
err = os.MkdirAll(filepath.Dir(outputPath), 0o777)
if err != nil {
log.Fatal().Err(err).Msg("failed to make output directory")
}
// one arg passed, so determine if it is file or directory
if len(args) > 0 {
if fileInfo, err = os.Stat(args[0]); err == nil {
if fileInfo.IsDir() {
err = compilePluginsDir(args[0], outputPath)
if err != nil {
log.Fatal().Err(err).
Bytes("output", output).
Msg("failed to compile plugin")
}
} else {
// not a directory so check if Go file so try and compile it
if filepath.Ext(args[0]) == ".go" {
output, err = compilePlugin(outputPath, args[0])
if err != nil {
log.Fatal().Err(err).
Bytes("output", output).
Msg("failed to compile plugin")
}
} else {
log.Fatal().Msg("argument is not a valid plugin (must be Go file)")
}
}
} else if err != nil {
log.Fatal().Err(err).Msgf("failed to stat provided plugin path")
}
} else {
// no args passed, so use current directory
err = compilePluginsDir(".", outputPath)
if err != nil {
log.Fatal().Err(err).
Bytes("output", output).
Msg("failed to compile plugin")
}
}
},
}
var pluginsInspectCmd = &cobra.Command{
Use: "inspect",
Args: cobra.MinimumNArgs(1),
Example: `
# inspect a plugin and print its information
makeshift plugin inspect lib/jinja2.so
`,
Run: func(cmd *cobra.Command, args []string) {
for _, path := range args {
var (
plugin makeshift.Plugin
err error
)
plugin, err = service.LoadPluginFromFile(path)
if err != nil {
log.Error().Err(err).
Str("path", path).
Msg("failed to load plugin from file")
continue
}
log.Info().Any("plugin", map[string]any{
"name": plugin.Name(),
"version": plugin.Version(),
"description": plugin.Description(),
"metadata": plugin.Metadata(),
}).Send()
}
},
}
var pluginsInfoCmd = &cobra.Command{
Use: "info",
Example: `
# show information of a remote plugin
makeshift plugins info jinja2 smd
# show information of a local plugin (same as 'makeshift inspect')
makeshift plugins info --local $MAKESHIFT_ROOT/plugins/jinja2.so
`,
Short: "Show plugin information",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
outputPath, _ = cmd.Flags().GetString("output")
local, _ = cmd.Flags().GetBool("local")
c = client.New(host)
res *http.Response
query string
body []byte
err error
)
log.Debug().
Str("host", host).
Str("output", outputPath).
Send()
if local {
var (
plugins []map[string]any
plugin makeshift.Plugin
err error
)
for _, path := range args {
plugin, err = service.LoadPluginFromFile(path)
if err != nil {
log.Error().Err(err).
Str("path", path).
Msg("failed to load plugin from path")
continue
}
plugins = append(plugins, makeshift.PluginToMap(plugin))
}
log.Info().Any("plugins", plugins).Send()
} else {
for _, pluginName := range args {
query = fmt.Sprintf("/plugins/%s/info", pluginName)
res, body, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodGet,
})
if err != nil {
log.Error().Err(err).
Str("host", host).
Str("query", query).
Msg("failed to make request")
os.Exit(1)
}
if res.StatusCode != http.StatusOK {
log.Error().
Any("status", map[string]any{
"code": res.StatusCode,
"message": res.Status,
"body": string(body),
}).
Str("host", host).
Msg("response returned bad status")
os.Exit(1)
}
if outputPath != "" {
writeFiles(outputPath, body)
} else {
fmt.Println(string(body))
}
}
}
},
}
func init() {
pluginsCompileCmd.Flags().StringP("output", "o", "", "Set the path to save compiled plugin (matches source type, i.e. uses files or directory)")
pluginsInfoCmd.Flags().String("host", "http://localhost:5050", "Set the makeshift remote host (can be set with MAKESHIFT_HOST)")
pluginsInfoCmd.Flags().Bool("local", false, "Set whether to display information of a local plugin")
pluginsCmd.AddCommand(pluginsCompileCmd, pluginsInspectCmd, pluginsInfoCmd)
rootCmd.AddCommand(pluginsCmd)
}
func compilePlugin(outputPath string, srcPath string) ([]byte, error) {
var (
commandArgs string
command *exec.Cmd
)
// execute command to build the plugin
commandArgs = fmt.Sprintf("go build -buildmode=plugin -o=%s %s", outputPath, srcPath)
command = exec.Command("bash", "-c", commandArgs)
return command.CombinedOutput()
}
func compilePluginsDir(dirpath string, outputPath string) error {
err := filepath.WalkDir(dirpath, func(path string, d fs.DirEntry, err error) error {
// not a directory and is Go file, so try and compile it
if !d.IsDir() && filepath.Ext(path) == ".go" {
var (
localOutputPath string = outputPath + "/" + path
)
output, err := compilePlugin(localOutputPath, path)
if err != nil {
log.Fatal().Err(err).
Bytes("output", output).
Str("path", localOutputPath).
Msg("failed to compile plugin")
return err
}
}
return nil
})
return err
}

View file

@ -2,37 +2,58 @@ package cmd
import (
"fmt"
"net/http"
"os"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
logger "git.towk2.me/towk/makeshift/pkg/log"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var (
conf config.Config
configPath string
cacertPath string
verbose bool
targets []string
outputPath string
accessToken string
remoteHost string
loglevel logger.LogLevel = logger.INFO
)
var rootCmd = &cobra.Command{
Use: "configurator",
Short: "Dynamically generate files defined by generators",
var rootCmd = cobra.Command{
Use: "makeshift",
Short: "Extensible file cobbler",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
var (
logFile string
err error
)
// initialize the logger
logFile, _ = cmd.Flags().GetString("log-file")
err = logger.InitWithLogLevel(loglevel, logFile)
if err != nil {
log.Error().Err(err).Msg("failed to initialize logger")
os.Exit(1)
}
},
Run: func(cmd *cobra.Command, args []string) {
// try and set flags using env vars
setenv(cmd, "log-file", "MAKESHIFT_LOG_FILE")
setenv(cmd, "log-level", "MAKESHIFT_LOG_LEVEL")
if len(args) == 0 {
cmd.Help()
err := cmd.Help()
if err != nil {
log.Error().Err(err).Msg("failed to print help")
}
os.Exit(0)
}
},
PostRun: func(cmd *cobra.Command, args []string) {
log.Debug().Msg("closing log file")
err := logger.LogFile.Close()
if err != nil {
log.Error().Err(err).Msg("failed to close log file")
}
},
}
func Execute() {
// run the main program
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@ -40,39 +61,60 @@ func Execute() {
}
func init() {
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)")
cobra.OnInitialize(
initLogger,
)
// initialize the config a single time
rootCmd.PersistentFlags().VarP(&loglevel, "log-level", "l", "Set the log level output")
rootCmd.PersistentFlags().String("log-file", "", "Set the log file path (can be set with MAKESHIFT_LOG_FILE)")
}
func InitConfig() {
// empty from not being set
if configPath != "" {
exists, err := util.PathExists(configPath)
func setenv(cmd *cobra.Command, varname string, envvar string) {
if cmd.Flags().Changed(varname) {
return
}
val := os.Getenv(envvar)
if val != "" {
cmd.Flags().Set(varname, val)
}
}
func setenvp(cmd *cobra.Command, varname string, envvar string) {
if cmd.Flags().Changed(varname) {
return
}
val := os.Getenv(envvar)
if val != "" {
cmd.PersistentFlags().Set(varname, val)
}
}
func initLogger() {
// initialize the logger
logfile, _ := rootCmd.PersistentFlags().GetString("log-file")
err := logger.InitWithLogLevel(loglevel, logfile)
if err != nil {
log.Error().Err(err).Str("path", configPath).Msg("failed to load config")
os.Exit(1)
} else if exists {
conf = config.Load(configPath)
} else {
// show error and exit since a path was specified
log.Error().Str("path", configPath).Msg("config file not found")
log.Error().Err(err).Msg("failed to initialize logger")
os.Exit(1)
}
} else {
// set to the default value and create a new one
configPath = "./config.yaml"
conf = config.New()
}
//
// set environment variables to override config values
//
// set the JWKS url if we find the CONFIGURATOR_JWKS_URL environment variable
jwksUrl := os.Getenv("CONFIGURATOR_JWKS_URL")
if jwksUrl != "" {
conf.Server.Jwks.Uri = jwksUrl
func handleResponseError(res *http.Response, host, query string, err error) {
if err != nil {
log.Error().Err(err).
Str("host", host).
Str("query", query).
Msg("failed to make request")
os.Exit(1)
}
if res.StatusCode != http.StatusOK {
log.Error().
Any("status", map[string]any{
"code": res.StatusCode,
"message": res.Status,
}).
Str("host", host).
Msg("response returned bad status")
os.Exit(1)
}
}

28
cmd/run.go Normal file
View file

@ -0,0 +1,28 @@
package cmd
import "github.com/spf13/cobra"
var runCmd = &cobra.Command{
Use: "run",
Example: `
NOTE: This command is not implemented yet!
# set up environment
export MAKESHIFT_HOST=http://localhost:5050
export MAKESHIFT_PATH=help.txt
export MAKESHIFT_ROOT=/opt/makeshift
# run locally similar to 'download'
makeshift run --plugins jinja2 --profiles default
makeshift run --root $HOME/apps/makeshift -p help.txt --plugins jinja2 --profiles default
`,
Args: cobra.NoArgs,
Short: "Run locally with plugins and profiles",
Run: func(cmd *cobra.Command, args []string) {
},
}
func init() {
rootCmd.AddCommand(runCmd)
}

View file

@ -1,67 +1,101 @@
//go:build server || all
// +build server all
package cmd
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"net/url"
"time"
"github.com/OpenCHAMI/configurator/pkg/server"
"git.towk2.me/towk/makeshift/pkg/service"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start configurator as a server and listen for requests",
Example: `
# start the service in current directory
makeshift serve
# start the service with root path and initialize
makeshift serve --root ./test --init -l debug
`,
Args: cobra.NoArgs,
PreRun: func(cmd *cobra.Command, args []string) {
setenv(cmd, "host", "MAKESHIFT_HOST")
setenv(cmd, "root", "MAKESHIFT_ROOT")
setenv(cmd, "timeout", "MAKESHIFT_TIMEOUT")
setenv(cmd, "cacert", "MAKESHIFT_CACERT")
setenv(cmd, "keyfile", "MAKESHIFT_KEYFILE")
},
Run: func(cmd *cobra.Command, args []string) {
// make sure that we have a token present before trying to make request
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 != "" {
conf.AccessToken = accessToken
} else {
if verbose {
log.Warn().Msg("No token found. Continuing without one...\n")
}
}
}
// show config as JSON and generators if verbose
if verbose {
b, err := json.MarshalIndent(conf, "", "\t")
if err != nil {
log.Error().Err(err).Msg("failed to marshal config")
os.Exit(1)
}
fmt.Printf("%v\n", string(b))
}
// start listening with the server
var (
s *server.Server = server.New(&conf)
err error = s.Serve()
host, _ = cmd.Flags().GetString("host")
rootPath, _ = cmd.Flags().GetString("root")
cacertPath, _ = cmd.Flags().GetString("cacert")
keyfile, _ = cmd.Flags().GetString("keyfile")
timeout, _ = cmd.Flags().GetInt("timeout")
parsed *url.URL
server *service.Service
err error
)
if errors.Is(err, http.ErrServerClosed) {
if verbose {
log.Info().Msg("server closed")
// parse the host to remove scheme if needed
parsed, err = url.Parse(host)
if err != nil {
log.Warn().Err(err).
Str("host", host).
Msg("could not parse host")
}
} else if err != nil {
log.Error().Err(err).Msg("failed to start server")
os.Exit(1)
// set the server values
server = service.New()
server.Addr = parsed.Host
server.RootPath = rootPath
server.CACertFile = cacertPath
server.CACertKeyfile = keyfile
server.Timeout = time.Duration(timeout) * time.Second
// show some debugging information
log.Debug().
Str("host", parsed.Host).
Any("paths", map[string]string{
"root": rootPath,
"cacert": cacertPath,
"keyfile": keyfile,
"data": server.PathForData(),
"profiles": server.PathForProfiles(),
"plugins": server.PathForPlugins(),
"metadata": server.PathForMetadata(),
}).
Send()
// make the default directories and files if flag is passed
if cmd.Flags().Changed("init") {
err = server.Init()
if err != nil {
log.Error().Err(err).
Str("host", parsed.Host).
Str("root", rootPath).
Msg("failed to initialize server root")
return
}
}
// serve and log why the server closed
err = server.Serve()
log.Error().Err(err).Msg("server closed")
},
}
func init() {
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")
serveCmd.Flags().Bool("init", false, "Initializes default files at specified with the '--root' flag")
serveCmd.Flags().String("host", "localhost:5050", "Set the configurator server host (can be set with MAKESHIFT_HOST)")
serveCmd.Flags().String("root", "./", "Set the root path to serve files (can be set with MAKESHIFT_ROOT)")
serveCmd.Flags().IntP("timeout", "t", 60, "Set the timeout in seconds for requests (can be set with MAKESHIFT_TIMEOUT)")
serveCmd.Flags().String("cacert", "", "Set the CA certificate path to load (can be set with MAKESHIFT_CACERT)")
serveCmd.Flags().String("keyfile", "", "Set the CA key file to use (can be set with MAKESHIFT_KEYFILE)")
serveCmd.MarkFlagsRequiredTogether("cacert", "keyfile")
rootCmd.AddCommand(serveCmd)
}

462
cmd/upload.go Normal file
View file

@ -0,0 +1,462 @@
package cmd
import (
"bufio"
"encoding/json"
"fmt"
"io/fs"
"maps"
"net/http"
"os"
"path/filepath"
"strings"
"git.towk2.me/towk/makeshift/internal/format"
makeshift "git.towk2.me/towk/makeshift/pkg"
"git.towk2.me/towk/makeshift/pkg/client"
"git.towk2.me/towk/makeshift/pkg/service"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var (
inputFormat format.DataFormat = format.JSON
)
var uploadCmd = &cobra.Command{
Use: "upload",
Example: `
# upload a single file in root directory
makeshift upload -d @compute-base.yaml
# upload a directory
makeshift upload -d @setup/
# upload an archive (extracted and saved on server)
makeshift upload -d @setup.tar.gz -t archive
# upload multiple files with a specific path (used to set remote location)
makeshift upload -d @kubernetes.json -p nodes/kubernetes.json
makeshift upload -d @slurm.json -d @compute.json -p nodes
`,
Short: "Upload files and directories",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
setenv(cmd, "host", "MAKESHIFT_HOST")
setenv(cmd, "path", "MAKESHIFT_PATH")
setenv(cmd, "cacert", "MAKESHIFT_CACERT")
},
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
path, _ = cmd.Flags().GetString("path")
cacertPath, _ = cmd.Flags().GetString("cacert")
dataArgs, _ = cmd.Flags().GetStringArray("data")
inputData = processFiles(dataArgs)
useDirectoryPath = len(inputData) > 1
c = client.New(host)
res *http.Response
query string
err error
)
log.Debug().
Str("host", host).
Str("path", path).
Str("query", query).
Str("cacert", cacertPath).
Any("input", inputData).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
for inputPath, contents := range inputData {
log.Debug().Str("path", path).Int("size", len(contents)).Send()
if useDirectoryPath {
query = path + "/" + filepath.Clean(inputPath)
} else {
// use flag value if supplied
if cmd.Flags().Changed("path") {
query = path
} else {
query = inputPath
}
}
query = fmt.Sprintf("/upload/%s", query)
res, _, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodPost,
Body: contents,
})
handleResponseError(res, host, query, err)
}
},
}
var uploadProfilesCmd = &cobra.Command{
Use: "profile [profile_id]",
Example: `
# upload a new profile
makeshift upload profile -d @compute.json kubernetes.json
# upload a new profile with a specific path
makeshift upload profile -d @kubernetes.json
makeshift upload profile -d '{"id": "custom", "data": {}}' kubernetes.json
`,
Short: "Upload a new profile",
Run: func(cmd *cobra.Command, args []string) {
var (
host, _ = cmd.Flags().GetString("host")
dataArgs, _ = cmd.Flags().GetStringArray("data")
cacertPath, _ = cmd.Flags().GetString("cacert")
profiles = processProfiles(dataArgs)
c = client.New(host)
res *http.Response
query string
body []byte
err error
)
log.Debug().
Str("host", host).
Str("query", query).
Str("cacert", cacertPath).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
// load files from args
for i, path := range args {
body, err = os.ReadFile(path)
if err != nil {
log.Error().Err(err).
Int("index", i).
Str("path", path).
Msg("failed to read profile file")
continue
}
var profile *makeshift.Profile
err = json.Unmarshal(body, &profile)
if err != nil {
log.Error().Err(err).
Int("index", i).
Str("path", path).
Msg("failed to unmarshal profile")
}
profiles = append(profiles, profile)
}
// send each loaded profile to server
for _, profile := range profiles {
if profile == nil {
continue
}
body, err = json.Marshal(profile)
if err != nil {
log.Error().Err(err).Msg("failed to marshal profile")
continue
}
query = fmt.Sprintf("/profiles/%s", profile.ID)
res, body, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodPost,
Body: body,
})
handleResponseError(res, host, query, err)
}
},
}
var uploadPluginsCmd = &cobra.Command{
Use: "plugin [plugin_name]",
Example: `
# upload a new plugin
makeshift upload plugin -d @slurm.so
makeshift upload plugin slurm.so
`,
Args: cobra.ExactArgs(1),
Short: "Upload a new plugin",
Run: func(cmd *cobra.Command, args []string) {
// make one request be host positional argument (restricted to 1 for now)
// temp := append(handleArgs(args), processDataArgs(dataArgs)...)
var (
host, _ = cmd.Flags().GetString("host")
dataArgs, _ = cmd.Flags().GetStringArray("data")
cacertPath, _ = cmd.Flags().GetString("cacert")
plugins = processFiles(dataArgs)
c = client.New(host)
res *http.Response
query string
body []byte
plugin makeshift.Plugin
err error
)
log.Debug().
Str("host", host).
Str("query", query).
Str("cacert", cacertPath).
Send()
if cacertPath != "" {
c.LoadCertificateFromPath(cacertPath)
}
// load files from args
for i, path := range args {
body, err = os.ReadFile(path)
if err != nil {
log.Error().Err(err).
Int("index", i).
Str("path", path).
Msg("failed to read plugin file")
continue
}
plugins[path] = body
}
for path, contents := range plugins {
plugin, err = service.LoadPluginFromFile(path)
if err != nil {
log.Error().Err(err).
Str("path", path).
Msg("failed to load plugin from file")
}
query = fmt.Sprintf("/plugins/%s", plugin.Name())
res, _, err = c.MakeRequest(client.HTTPEnvelope{
Path: query,
Method: http.MethodPost,
Body: contents,
})
handleResponseError(res, host, query, err)
}
},
}
func init() {
uploadCmd.PersistentFlags().String("host", "http://localhost:5050", "Set the makeshift remote host (can be set with MAKESHIFT_HOST)")
uploadCmd.PersistentFlags().StringArrayP("data", "d", []string{}, "Set the data to send to specified host (prepend @ for files)")
uploadCmd.PersistentFlags().String("cacert", "", "Set the CA certificate path to load")
uploadCmd.Flags().StringP("path", "p", ".", "Set the path to list files (can be set with MAKESHIFT_PATH)")
uploadProfilesCmd.Flags().VarP(&inputFormat, "format", "F", "Set the input format for profile")
uploadCmd.AddCommand(uploadProfilesCmd, uploadPluginsCmd)
rootCmd.AddCommand(uploadCmd)
}
func processFiles(args []string) map[string][]byte {
// load data either from file or directly from args
var collection = make(map[string][]byte, len(args))
for _, arg := range args {
// skip empty string args
if len(arg) > 0 {
// determine if we're reading from file to load contents
if strings.HasPrefix(arg, "@") {
var path string = strings.TrimLeft(arg, "@")
// process sub-directories recursively
newCollection, err := processDir(path)
if err != nil {
log.Warn().
Err(err).
Str("path", path).
Msg("failed to process directory at path")
}
log.Trace().
Str("path", path).
Msg("new collection added at path")
maps.Copy(collection, newCollection)
} else {
log.Warn().Msg("only files can be uploaded (add @ before the path with '--data' flag)")
continue
}
}
}
return collection
}
// processProfiles takes a slice of strings that check for the @ symbol and loads
// the contents from the file specified in place (which replaces the path).
//
// NOTE: The purpose is to make the input arguments uniform for our request. This
// function is meant to handle data passed with the `-d/--data` flag and positional
// args from the CLI.
func processProfiles(args []string) []*makeshift.Profile {
// load data either from file or directly from args
var collection = make([]*makeshift.Profile, len(args))
for i, arg := range args {
// if arg is empty string, then skip and continue
if len(arg) > 0 {
// determine if we're reading from file to load contents
if strings.HasPrefix(arg, "@") {
var (
path string = strings.TrimLeft(arg, "@")
contents []byte
data *makeshift.Profile
err error
)
contents, err = os.ReadFile(path)
if err != nil {
log.Error().
Err(err).
Str("path", path).
Msg("failed to read file")
continue
}
// skip empty files
if len(contents) == 0 {
log.Warn().
Str("path", path).
Msg("file is empty")
continue
}
// convert/validate input data
data, err = parseProfile(contents, format.DataFormatFromFileExt(path, inputFormat))
if err != nil {
log.Error().
Err(err).
Str("path", path).
Msg("failed to validate input from file")
}
// add loaded data to collection of all data
collection = append(collection, data)
} else {
// input should be a valid JSON
var (
data *makeshift.Profile
input = []byte(arg)
err error
)
if !json.Valid(input) {
log.Error().Msgf("argument %d not a valid JSON", i)
continue
}
err = json.Unmarshal(input, &data)
if err != nil {
log.Error().
Err(err).
Msgf("failed to unmarshal input for argument %d", i)
}
return []*makeshift.Profile{data}
}
}
}
return collection
}
func processDir(path string) (map[string][]byte, error) {
var (
collection = map[string][]byte{}
fileInfo os.FileInfo
contents []byte
err error
)
// determine if path is directory
if fileInfo, err = os.Stat(path); err == nil {
if fileInfo.IsDir() {
filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
contents, err = os.ReadFile(path)
if err != nil {
log.Error().Err(err).Str("path", path).Msg("failed to read file")
return nil
}
// skip empty files
if len(contents) == 0 {
log.Warn().Str("path", path).Msg("file is empty")
return nil
}
log.Debug().
Str("path", path).
Msg("file added to collection")
// add loaded data to collection of all data
collection[path] = contents
} else {
// process sub-directories recursively
newCollection, err := processDir(path)
if err != nil {
return fmt.Errorf("failed to process directory at path '%s': %v", path, err)
}
log.Trace().
Str("path", path).
Msg("new collection added from nested directory")
maps.Copy(collection, newCollection)
}
return nil
})
} else {
contents, err = os.ReadFile(path)
if err != nil {
return collection, fmt.Errorf("failed to read file at path '%s': %v", path, err)
}
// skip empty files
if len(contents) == 0 {
return collection, fmt.Errorf("file is empty")
}
log.Debug().
Str("path", path).
Msg("file added to collection")
// add loaded data to collection of all data
collection[path] = contents
}
} else {
return nil, fmt.Errorf("failed to stat file: %v", err)
}
return collection, nil
}
func parseProfile(contents []byte, dataFormat format.DataFormat) (*makeshift.Profile, error) {
var (
data *makeshift.Profile
err error
)
// convert/validate JSON input format
err = format.Unmarshal(contents, &data, dataFormat)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal profile: %v", err)
}
return data, nil
}
// ReadStdin reads all of standard input and returns the bytes. If an error
// occurs during scanning, it is returned.
func ReadStdin() ([]byte, error) {
var b []byte
input := bufio.NewScanner(os.Stdin)
for input.Scan() {
b = append(b, input.Bytes()...)
b = append(b, byte('\n'))
if len(b) == 0 {
break
}
}
if err := input.Err(); err != nil {
return b, fmt.Errorf("failed to read stdin: %w", err)
}
return b, nil
}

View file

@ -1,23 +0,0 @@
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

@ -1,23 +0,0 @@
#
# 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
#
SERVER keepalive=ON
SERVER logdir="/var/log/conman"
SERVER logfile="/var/log/conman.log"
SERVER loopback=ON
SERVER pidfile="/var/run/conman.pid"
SERVER resetcmd="/usr/bin/powerman -0 \%N; sleep 5; /usr/bin/powerman -1 \%N"
SERVER tcpwrappers=ON
#SERVER timestamp=1h
GLOBAL seropts="115200,8n1"
GLOBAL log="/var/log/conman/console.\%N"
GLOBAL logopts="sanitize,timestamp"
{{ consoles }}

View file

@ -1,51 +0,0 @@
#
# 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
#
allow booting;
allow bootp;
ddns-update-style interim;
authoritative;
option space ipxe;
# Tell iPXE to not wait for ProxyDHCP requests to speed up boot.
option ipxe.no-pxedhcp code 176 = unsigned integer 8;
option ipxe.no-pxedhcp 1;
option architecture-type code 93 = unsigned integer 16;
if exists user-class and option user-class = "iPXE" {
filename "http://%{IPADDR}/WW/ipxe/cfg/${mac}";
} else {
if option architecture-type = 00:0B {
filename "/warewulf/ipxe/bin-arm64-efi/snp.efi";
} elsif option architecture-type = 00:0A {
filename "/warewulf/ipxe/bin-arm32-efi/placeholder.efi";
} elsif option architecture-type = 00:09 {
filename "/warewulf/ipxe/bin-x86_64-efi/snp.efi";
} elsif option architecture-type = 00:07 {
filename "/warewulf/ipxe/bin-x86_64-efi/snp.efi";
} elsif option architecture-type = 00:06 {
filename "/warewulf/ipxe/bin-i386-efi/snp.efi";
} elsif option architecture-type = 00:00 {
filename "/warewulf/ipxe/bin-i386-pcbios/undionly.kpxe";
}
}
subnet %{NETWORK} netmask %{NETMASK} {
not authoritative;
# option interface-mtu 9000;
option subnet-mask %{NETMASK};
}
# Compute Nodes (WIP - see the dhcpd generator plugin)
{{ compute_nodes }}
# Node entries will follow below
{{ node_entries }}

View file

@ -1,10 +0,0 @@
#
# 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
#
{{ dhcp_hosts }}

View file

@ -1,18 +0,0 @@
#
# 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
#
include "/etc/powerman/ipmipower.dev"
include "/etc/powerman/ipmi.dev"
# list of devices
{{ devices }}
# create nodes based on found nodes in hostfile
{{ nodes }}

View file

@ -1,16 +0,0 @@
#
# 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

31
go.mod
View file

@ -1,20 +1,21 @@
module github.com/OpenCHAMI/configurator
module git.towk2.me/towk/makeshift
go 1.21.5
go 1.24.4
toolchain go1.24.6
require (
github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18
github.com/cavaliergopher/grab/v3 v3.0.1
github.com/go-chi/chi/v5 v5.1.0
github.com/lestrrat-go/jwx/v2 v2.1.1
github.com/nikolalohinski/gonja/v2 v2.2.0
github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700
github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700
github.com/rodaine/table v1.2.0
github.com/rs/zerolog v1.33.0
github.com/sirupsen/logrus v1.9.3
github.com/nikolalohinski/gonja/v2 v2.3.5
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.8.0
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8
gopkg.in/yaml.v2 v2.4.0
github.com/tidwall/sjson v1.2.5
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
gopkg.in/yaml.v3 v3.0.1
)
require (
@ -23,7 +24,7 @@ require (
github.com/goccy/go-json v0.10.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kr/text v0.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
@ -35,8 +36,12 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tidwall/gjson v1.14.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.23.0 // indirect
)

98
go.sum
View file

@ -2,8 +2,11 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18 h1:oBPtXp9RVm9lk5zTmDLf+Vh21yDHpulBxUqGJQjwQCk=
github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18/go.mod h1:ggNHWgLfW/WRXcE8ZZC4S7UwHif16HVmyowOCWdNSN8=
github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4=
github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -13,27 +16,26 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
@ -51,34 +53,27 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nikolalohinski/gonja/v2 v2.2.0 h1:tAs3BDHNjvPj48F2BL5t7iVhN32HhgeldAl3EmdsLh8=
github.com/nikolalohinski/gonja/v2 v2.2.0/go.mod h1:l9DuWJvT/BddBr2SsmEimESD6msSqRw7u5HzI2Um+sc=
github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU=
github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM=
github.com/onsi/gomega v1.27.8 h1:gegWiwZjBsf2DgiSbf5hpokZ98JVDMcWkUiigk6/KXc=
github.com/onsi/gomega v1.27.8/go.mod h1:2J8vzI/s+2shY9XHRApDkdgPo1TKT7P2u6fXeJKFnNQ=
github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 h1:XADGipD2FZ9swuFUqeL7h63j3voiq9qA7P0aKsqgZKg=
github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700/go.mod h1:kswb9kU5cZAFRAvf1dAUJRWbQyjDEb0qkxW4ncDdEXg=
github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 h1:Gzt5f6RK39CHvY3SJudzBb/RK4tVh/S3CpJ0eQlbNdg=
github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700/go.mod h1:UuXvr2loD4MtvZeKr57W0WpBs+gm0KM1kdtcXrE8M6s=
github.com/nikolalohinski/gonja/v2 v2.3.5 h1:7ukCnsokmOIGXOjgW/WrM+xqgwjsQcU0ejFrrz4HQXk=
github.com/nikolalohinski/gonja/v2 v2.3.5/go.mod h1:UIzXPVuOsr5h7dZ5DUbqk3/Z7oFA/NLGQGMjqT4L2aU=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
@ -89,38 +84,43 @@ github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyh
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

220
internal/archive/archive.go Normal file
View file

@ -0,0 +1,220 @@
package archive
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
makeshift "git.towk2.me/towk/makeshift/pkg"
)
func Create(filenames []string, buf io.Writer, hooks []makeshift.Hook) 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 _, filename := range filenames {
err := addToArchive(tw, filename, hooks)
if err != nil {
return err
}
}
return nil
}
func Expand(tarname, xpath string) error {
tarfile, err := os.Open(tarname)
if err != nil {
return err
}
defer tarfile.Close()
// absPath, err := filepath.Abs(xpath)
// if err != nil {
// return err
// }
tr := tar.NewReader(tarfile)
if strings.HasSuffix(tarname, ".gz") {
gz, err := gzip.NewReader(tarfile)
if err != nil {
return fmt.Errorf("failed to create new gzip reader: %v", err)
}
defer gz.Close()
tr = tar.NewReader(gz)
}
// untar each segment
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to get next tar header: %v", err)
}
// determine proper file path info
var (
fileinfo = header.FileInfo()
filename = header.Name
file *os.File
abspath string
dirpath string
)
// absFileName := filepath.Join(absPath, filename) // if a dir, create it, then go to next segment
if fileinfo.Mode().IsDir() {
if err := os.MkdirAll(filename, 0o755); err != nil {
return fmt.Errorf("failed to make directory '%s': %v", filename, err)
}
continue
}
dirpath = filepath.Dir(filename)
if err = os.MkdirAll(dirpath, 0o777); err != nil {
return fmt.Errorf("failed to make directory '%s': %v", err)
}
// create new file with original file mode
abspath, err = filepath.Abs(filename)
if err != nil {
return fmt.Errorf("failed to get absolute path: %v", err)
}
file, err = os.OpenFile(
abspath,
os.O_RDWR|os.O_CREATE|os.O_TRUNC,
fileinfo.Mode().Perm(),
)
if err != nil {
return fmt.Errorf("failed to open file: %v", err)
}
// fmt.Printf("x %s\n", filename)
// copy the contents to the new file
n, err := io.Copy(file, tr)
if err != nil {
return fmt.Errorf("failed to copy file: %v", err)
}
if err = file.Close(); err != nil {
return fmt.Errorf("failed to close file: %v", err)
}
if n != fileinfo.Size() {
return fmt.Errorf("wrote %d, want %d", n, fileinfo.Size())
}
}
return nil
}
func addToArchive(tw *tar.Writer, filename string, hooks []makeshift.Hook) error {
var (
tempfile = fmt.Sprintf("%s.tmp", filename)
file *os.File
contents []byte
data any
err error
)
// run pre-hooks to modify the contents of the file
// before archiving using plugins
for _, hook := range hooks {
// set the file in the data store before running hook
contents, err = os.ReadFile(filename)
if err != nil {
return fmt.Errorf("failed to read '%s' to download: %v", filename, err)
}
hook.Data.Set("file", contents)
err = hook.Init()
if err != nil {
return err
}
err = hook.Run()
if err != nil {
return err
}
err = hook.Cleanup()
if err != nil {
return err
}
// create temporary file to use to add to archive
hook = hooks[len(hooks)-1]
data, err = hook.Data.Get("out")
if err != nil {
return fmt.Errorf("failed to get output data from '%s' plugin: %v", hook.Plugin.Name(), err)
}
err = os.WriteFile(tempfile, data.([]byte), 0o777)
if err != nil {
return fmt.Errorf("failed to write temporary file: %v", err)
}
}
// use original file if no hooks to write archive
if len(hooks) == 0 {
file, err = os.Open(filename)
} else {
file, err = os.Open(tempfile)
}
if err != nil {
return fmt.Errorf("failed to open archive file: %v", err)
}
defer file.Close()
// get FileInfo for file size, mode, etc.
info, err := file.Stat()
if err != nil {
return err
}
// skip file if it's a directory
if info.IsDir() {
return nil
}
// create a tar Header from the FileInfo data
header, err := tar.FileInfoHeader(info, info.Name())
if err != nil {
return fmt.Errorf("failed to create FileInfoHeader: %v", 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
if len(hooks) == 0 {
_, err = io.Copy(tw, file)
} else {
_, err = io.Copy(tw, strings.NewReader(string(data.([]byte))))
}
if err != nil {
return err
}
// delete the temporary file since we're done with it
if len(hooks) != 0 {
err = os.Remove(tempfile)
if err != nil {
return err
}
}
return nil
}

104
internal/format/format.go Normal file
View file

@ -0,0 +1,104 @@
package format
import (
"encoding/json"
"fmt"
"path/filepath"
"gopkg.in/yaml.v3"
)
type DataFormat string
const (
List DataFormat = "list"
JSON DataFormat = "json"
YAML DataFormat = "yaml"
)
func (df DataFormat) String() string {
return string(df)
}
func (df *DataFormat) Set(v string) error {
switch DataFormat(v) {
case List, JSON, YAML:
*df = DataFormat(v)
return nil
default:
return fmt.Errorf("must be one of %v", []DataFormat{
List, JSON, YAML,
})
}
}
func (df DataFormat) Type() string {
return "DataFormat"
}
// MarshalData marshals arbitrary data into a byte slice formatted as outFormat.
// If a marshalling error occurs or outFormat is unknown, an error is returned.
//
// Supported values are: json, list, yaml
func Marshal(data interface{}, outFormat DataFormat) ([]byte, error) {
switch outFormat {
case JSON:
if bytes, err := json.MarshalIndent(data, "", " "); err != nil {
return nil, fmt.Errorf("failed to marshal data into JSON: %w", err)
} else {
return bytes, nil
}
case YAML:
if bytes, err := yaml.Marshal(data); err != nil {
return nil, fmt.Errorf("failed to marshal data into YAML: %w", err)
} else {
return bytes, nil
}
case List:
return nil, fmt.Errorf("this data format cannot be marshaled")
default:
return nil, fmt.Errorf("unknown data format: %s", outFormat)
}
}
// UnmarshalData unmarshals a byte slice formatted as inFormat into an interface
// v. If an unmarshalling error occurs or inFormat is unknown, an error is
// returned.
//
// Supported values are: json, list, yaml
func Unmarshal(data []byte, v interface{}, inFormat DataFormat) error {
switch inFormat {
case JSON:
if err := json.Unmarshal(data, v); err != nil {
return fmt.Errorf("failed to unmarshal data into JSON: %w", err)
}
case YAML:
if err := yaml.Unmarshal(data, v); err != nil {
return fmt.Errorf("failed to unmarshal data into YAML: %w", err)
}
case List:
return fmt.Errorf("this data format cannot be unmarshaled")
default:
return fmt.Errorf("unknown data format: %s", inFormat)
}
return nil
}
// DataFormatFromFileExt determines the type of the contents
// (JSON or YAML) based on the filname extension. The default
// format is passed in, so if it doesn't match one of the cases,
// that's what we will use. The defaultFmt value takes into account
// both the standard default format (JSON) and any command line
// change to that provided by options.
func DataFormatFromFileExt(path string, defaultFmt DataFormat) DataFormat {
switch filepath.Ext(path) {
case ".json", ".JSON":
// The file is a JSON file
return JSON
case ".yaml", ".yml", ".YAML", ".YML":
// The file is a YAML file
return YAML
}
return defaultFmt
}

0
lib/.gitkeep Normal file
View file

View file

@ -1,6 +1,6 @@
package main
import "github.com/OpenCHAMI/configurator/cmd"
import "git.towk2.me/towk/makeshift/cmd"
func main() {
cmd.Execute()

View file

@ -3,45 +3,139 @@ package client
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"os"
"strings"
"time"
"git.towk2.me/towk/makeshift/pkg/util"
"github.com/cavaliergopher/grab/v3"
)
type Option func(*Params)
type Params struct {
Host string `yaml:"host"`
AccessToken string `yaml:"access-token"`
Transport *http.Transport
type HTTPBody []byte
type HTTPHeader map[string]string
type HTTPEnvelope struct {
Path string
Method string
Header HTTPHeader
Body HTTPBody
CACert string
}
func ToParams(opts ...Option) *Params {
params := &Params{}
for _, opt := range opts {
opt(params)
}
return params
type Client struct {
http.Client
BaseURI string
AccessToken string
}
func WithHost(host string) Option {
return func(c *Params) {
c.Host = host
func New(uri string) Client {
return Client{
BaseURI: strings.TrimSuffix(uri, "/"),
}
}
func WithAccessToken(token string) Option {
return func(c *Params) {
c.AccessToken = token
func NewHTTPEnvelope() HTTPEnvelope {
return HTTPEnvelope{
Path: "",
Method: http.MethodGet,
Header: nil,
Body: nil,
CACert: "",
}
}
func WithCertPool(certPool *x509.CertPool) Option {
return func(c *Params) {
func (c *Client) MakeRequest(env HTTPEnvelope) (*http.Response, []byte, error) {
return util.MakeRequest(c.BaseURI+env.Path, env.Method, env.Body, env.Header)
}
func (c *Client) Download(out string, env HTTPEnvelope) (*grab.Response, error) {
if out == "" {
return grab.Get(out, c.BaseURI+env.Path)
}
return grab.Get(out, c.BaseURI+env.Path)
}
func (c *Client) UploadMultipartFile(uri, key, path string) (*http.Response, error) {
body, writer := io.Pipe()
req, err := http.NewRequest(http.MethodPost, uri, body)
if err != nil {
return nil, err
}
mwriter := multipart.NewWriter(writer)
req.Header.Add("Content-Type", mwriter.FormDataContentType())
errchan := make(chan error)
go func() {
defer close(errchan)
defer writer.Close()
defer mwriter.Close()
w, err := mwriter.CreateFormFile(key, path)
if err != nil {
errchan <- err
return
}
in, err := os.Open(path)
if err != nil {
errchan <- err
return
}
defer in.Close()
if written, err := io.Copy(w, in); err != nil {
errchan <- fmt.Errorf("error copying %s (%d bytes written): %v", path, written, err)
return
}
if err := mwriter.Close(); err != nil {
errchan <- err
return
}
}()
resp, err := c.Do(req)
merr := <-errchan
if err != nil || merr != nil {
return resp, fmt.Errorf("http error: %v, multipart error: %v", err, merr)
}
return resp, nil
}
func (c *Client) LoadCertificateFromPath(path string) error {
cacert, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read certificate at path: %s", path)
}
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(cacert)
err = c.LoadCertificateFromPool(certPool)
if err != nil {
return fmt.Errorf("could not initialize certificate from pool: %v", err)
}
return nil
}
func (c *Client) LoadCertificateFromPool(certPool *x509.CertPool) error {
// make sure we have a valid cert pool
if certPool == nil {
return fmt.Errorf("invalid cert pool")
}
// make sure that we can access the internal client
c.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
InsecureSkipVerify: true,
InsecureSkipVerify: false,
},
DisableKeepAlives: true,
Dial: (&net.Dialer{
@ -51,16 +145,13 @@ func WithCertPool(certPool *x509.CertPool) Option {
TLSHandshakeTimeout: 120 * time.Second,
ResponseHeaderTimeout: 120 * time.Second,
}
}
return nil
}
// FIXME: Need to check for errors when reading from a file
func WithCertPoolFile(certPath string) Option {
if certPath == "" {
return func(sc *Params) {}
func mustOpen(f string) *os.File {
r, err := os.Open(f)
if err != nil {
panic(err)
}
cacert, _ := os.ReadFile(certPath)
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(cacert)
return WithCertPool(certPool)
return r
}

View file

@ -1,174 +0,0 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/rs/zerolog/log"
)
// 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:"-" yaml:"-"`
Host string `yaml:"host"`
Port int `yaml:"port"`
AccessToken string `yaml:"access-token"`
}
// Constructor function that allows supplying Option arguments to set
// things like the host, port, access token, etc.
func NewSmdClient(opts ...Option) SmdClient {
var (
params = ToParams(opts...)
client = SmdClient{
Host: params.Host,
AccessToken: params.AccessToken,
}
)
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(verbose bool) ([]configurator.EthernetInterface, error) {
var (
eths = []configurator.EthernetInterface{}
bytes []byte
err error
)
// make request to SMD endpoint
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(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 {
log.Info().Str("ethernet_interfaces", string(bytes)).Msg("found interfaces")
}
return eths, nil
}
// 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(verbose bool) ([]configurator.Component, error) {
var (
comps = []configurator.Component{}
bytes []byte
err error
)
// make request to SMD endpoint
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(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(bytes, &tmp)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
bytes, err = json.Marshal(tmp["RedfishEndpoints"].([]any))
if err != nil {
return nil, fmt.Errorf("failed to marshal JSON: %v", err)
}
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 {
log.Info().Str("components", string(bytes)).Msg("found components")
}
return comps, nil
}
// TODO: improve implementation of this function
func (client *SmdClient) FetchRedfishEndpoints(verbose bool) ([]configurator.RedfishEndpoint, error) {
var (
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))
}
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)
}
err = json.Unmarshal(b, &eps)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
// show the final result
if verbose {
log.Info().Str("redfish_endpoints", string(b)).Msg("found redfish endpoints")
}
return eps, nil
}
func (client *SmdClient) makeRequest(endpoint string) ([]byte, error) {
if client == nil {
return nil, fmt.Errorf("client is nil")
}
// fetch DHCP related information from SMD's 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)
}
// include access token in authorzation header if found
// NOTE: This shouldn't be needed for this endpoint since it's public
if client.AccessToken != "" {
req.Header.Add("Authorization", "Bearer "+client.AccessToken)
}
// make the request to SMD
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %v", err)
}
// read the contents of the response body
return io.ReadAll(res.Body)
}

View file

@ -1,100 +0,0 @@
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

@ -1,66 +0,0 @@
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"`
}
type EthernetInterface struct {
Id string
Description string
MacAddress string
LastUpdate string
ComponentId string
Type string
IpAddresses []IPAddr
}
type Component struct {
ID string `json:"ID"`
Type string `json:"Type"`
State string `json:"State,omitempty"`
Flag string `json:"Flag,omitempty"`
Enabled *bool `json:"Enabled,omitempty"`
SwStatus string `json:"SoftwareStatus,omitempty"`
Role string `json:"Role,omitempty"`
SubRole string `json:"SubRole,omitempty"`
NID json.Number `json:"NID,omitempty"`
Subtype string `json:"Subtype,omitempty"`
NetType string `json:"NetType,omitempty"`
Arch string `json:"Arch,omitempty"`
Class string `json:"Class,omitempty"`
ReservationDisabled bool `json:"ReservationDisabled,omitempty"`
Locked bool `json:"Locked,omitempty"`
}
type RedfishEndpoint struct {
ID string `json:"ID"`
Type string `json:"Type"`
Name string `json:"Name,omitempty"` // user supplied descriptive name
Hostname string `json:"Hostname"`
Domain string `json:"Domain"`
FQDN string `json:"FQDN"`
Enabled bool `json:"Enabled"`
UUID string `json:"UUID,omitempty"`
User string `json:"User"`
Password string `json:"Password"` // Temporary until more secure method
UseSSDP bool `json:"UseSSDP,omitempty"`
MACRequired bool `json:"MACRequired,omitempty"`
MACAddr string `json:"MACAddr,omitempty"`
IPAddr string `json:"IPAddress,omitempty"`
}
type Node struct {
}
type BMC struct {
}

View file

@ -1,56 +0,0 @@
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 Conman struct{}
func (g *Conman) GetName() string {
return "conman"
}
func (g *Conman) GetVersion() string {
return util.GitCommit()
}
func (g *Conman) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *Conman) Generate(config *config.Config, params Params) (FileMap, error) {
var (
smdClient = client.NewSmdClient(params.ClientOpts...)
eps = []configurator.RedfishEndpoint{}
err error = nil
consoles = ""
)
// fetch required data from SMD to create config
eps, err = smdClient.FetchRedfishEndpoints(params.Verbose)
if err != nil {
return nil, fmt.Errorf("failed to fetch redfish endpoints with client: %v", err)
}
// format output to write to config file
consoles = "# ========== DYNAMICALLY GENERATED BY OPENCHAMI CONFIGURATOR ==========\n"
for _, ep := range eps {
consoles += fmt.Sprintf("CONSOLE name=%s dev=ipmi:%s-bmc ipmiopts=U:%s,P:%s,W:solpayloadsize\n", ep.Name, ep.Name, ep.User, ep.Password)
}
consoles += "# ====================================================================="
// apply template substitutions and return output as byte array
return ApplyTemplates(Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
"server_opts": "",
"global_opts": "",
"consoles": consoles,
}, params.Templates)
}

View file

@ -1,26 +0,0 @@
package generator
import (
"fmt"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
type CoreDhcp struct{}
func (g *CoreDhcp) GetName() string {
return "coredhcp"
}
func (g *CoreDhcp) GetVersion() string {
return util.GitCommit()
}
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 *config.Config, params Params) (FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}

View file

@ -1,64 +0,0 @@
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,71 +0,0 @@
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 DNSMasq struct{}
func (g *DNSMasq) GetName() string {
return "dnsmasq"
}
func (g *DNSMasq) GetVersion() string {
return util.GitCommit()
}
func (g *DNSMasq) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
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)")
}
// set all the defaults for variables
var (
smdClient = client.NewSmdClient(params.ClientOpts...)
eths = []configurator.EthernetInterface{}
err error = nil
)
// 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")
}
// format output to write to config file
output := "# ========== DYNAMICALLY GENERATED BY OPENCHAMI CONFIGURATOR ==========\n"
for _, eth := range eths {
if eth.Type == "NodeBMC" {
output += "dhcp-host=" + eth.MacAddress + "," + eth.ComponentId + "," + eth.IpAddresses[0].IpAddress + "\n"
} else {
output += "dhcp-host=" + eth.MacAddress + "," + eth.ComponentId + "," + eth.IpAddresses[0].IpAddress + "\n"
}
}
output += "# ====================================================================="
// apply template substitutions and return output as byte array
return ApplyTemplates(Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
"dhcp_hosts": output,
}, params.Templates)
}

View file

@ -1,31 +0,0 @@
package generator
import (
"fmt"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
type Example struct {
Message string
}
func (g *Example) GetName() string {
return "example"
}
func (g *Example) GetVersion() string {
return util.GitCommit()
}
func (g *Example) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
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 FileMap{"example": []byte(g.Message)}, nil
}

View file

@ -1,264 +0,0 @@
package generator
import (
"fmt"
"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/rs/zerolog/log"
)
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.
Generator interface {
GetName() string
GetVersion() string
GetDescription() string
Generate(config *config.Config, params Params) (FileMap, error)
}
)
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.
func ConvertContentsToString(f FileMap) map[string]string {
n := make(map[string]string, len(f))
for k, v := range f {
n[k] = string(v)
}
return n
}
// Loads files without applying any Jinja 2 templating.
func LoadFiles(paths ...string) (FileMap, error) {
var outputs = FileMap{}
for _, path := range paths {
expandedPaths, err := filepath.Glob(path)
if err != nil {
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: %w", err)
}
// skip any directories found
if info.IsDir() {
continue
}
b, err := os.ReadFile(expandedPath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
outputs[expandedPath] = b
}
}
return outputs, nil
}
// Loads a single generator plugin given a single file path.
func LoadPlugin(path string) (Generator, error) {
// skip loading plugin if path is a directory with no 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 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: %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': %w", path, err)
}
// assert that the plugin loaded has a valid generator
gen, ok := symbol.(Generator)
if !ok {
return nil, fmt.Errorf("failed to load the correct symbol type at path '%s'", path)
}
return gen, nil
}
// Loads all generator plugins in a given directory.
//
// Returns a map of generators. Each generator can be accessed by the name
// returned by the generator.GetName() implemented.
func LoadPlugins(dirpath string, opts ...Option) (map[string]Generator, error) {
// check if verbose option is supplied
var (
generators = make(map[string]Generator)
params = ToParams(opts...)
)
//
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
}
// only try loading if file has .so extension
if filepath.Ext(path) != ".so" {
return nil
}
// load the generator plugin from current path
gen, err := LoadPlugin(path)
if err != nil {
return fmt.Errorf("failed to load generator in directory '%s': %w", path, err)
}
// show the plugins found if verbose flag is set
if params.Verbose {
log.Info().Str("plugin_name", gen.GetName()).Msg("found plugin")
}
// 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 generators, nil
}
// 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 (
gen Generator
ok bool
err error
)
// 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 load plugin from file: %v", err)
}
}
return gen.Generate(config, params)
}
// Main function to generate a collection of files as a map with the path as the key and
// the contents of the file as the value. This function currently expects a list of plugin
// paths to load all plugins within a directory. Then, each plugin's generator.GenerateWithTarget()
// function is called for each target specified.
//
// This function is the corresponding implementation for the "generate" CLI subcommand.
// 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 *config.Config, target string) (FileMap, error) {
// load generator plugins to generate configs or to print
var (
opts []client.Option
targetInfo configurator.Target
generator Generator
params Params
err error
ok bool
)
// check if a target is supplied
if target == "" {
return nil, fmt.Errorf("must specify a target")
}
// load target information from config
targetInfo, ok = config.Targets[target]
if !ok {
log.Warn().Str("target", target).Msg("target not found in config")
}
// 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)
}
}
// 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
return generator.Generate(config, params)
}

View file

@ -1,26 +0,0 @@
package generator
import (
"fmt"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
type Hostfile struct{}
func (g *Hostfile) GetName() string {
return "hostfile"
}
func (g *Hostfile) GetVersion() string {
return util.GitCommit()
}
func (g *Hostfile) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *Hostfile) Generate(config *config.Config, opts ...Option) (FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}

View file

@ -1,43 +0,0 @@
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,26 +0,0 @@
package generator
import (
"fmt"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
type Powerman struct{}
func (g *Powerman) GetName() string {
return "powerman"
}
func (g *Powerman) GetVersion() string {
return util.GitCommit()
}
func (g *Powerman) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *Powerman) Generate(config *config.Config, opts ...Option) (FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}

View file

@ -1,26 +0,0 @@
package generator
import (
"fmt"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
)
type Syslog struct{}
func (g *Syslog) GetName() string {
return "syslog"
}
func (g *Syslog) GetVersion() string {
return util.GitCommit()
}
func (g *Syslog) GetDescription() string {
return fmt.Sprintf("Configurator generator plugin for '%s'.", g.GetName())
}
func (g *Syslog) Generate(config *config.Config, opts ...Option) (FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}

View file

@ -1,98 +0,0 @@
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
}

View file

@ -1,78 +0,0 @@
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
}

119
pkg/log/log.go Normal file
View file

@ -0,0 +1,119 @@
package log
import (
"fmt"
"io"
"os"
"slices"
"strings"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// string representation that directly corresponds to zerolog.Level
type LogLevel string
type LogLevelList []LogLevel
type LogFilter string
const (
DEBUG LogLevel = "debug"
INFO LogLevel = "info"
WARN LogLevel = "warn"
ERROR LogLevel = "error"
DISABLED LogLevel = "disabled"
TRACE LogLevel = "trace"
)
var Levels = [6]LogLevel{DEBUG, INFO, WARN, ERROR, DISABLED, TRACE}
var LogFile *os.File
func (ll LogLevel) String() string {
return string(ll)
}
func (ll *LogLevel) Set(v string) error {
switch LogLevel(v) {
case DEBUG, INFO, WARN, ERROR, DISABLED, TRACE:
*ll = LogLevel(v)
return nil
default:
return fmt.Errorf("must be one of %v", []LogLevel{
DEBUG,
INFO,
WARN,
ERROR,
DISABLED,
TRACE,
})
}
}
func (df LogLevel) Type() string {
return "LogLevel"
}
func InitWithLogLevel(logLevel LogLevel, logPath string) error {
var (
logger zerolog.Logger
level zerolog.Level
writer zerolog.LevelWriter
writers []io.Writer
err error
)
// set the logging level
level, err = strToLogLevel(logLevel)
if err != nil {
return fmt.Errorf("failed to convert log level: %v", err)
}
// add the default stderr writer
writers = append(writers, &zerolog.FilteredLevelWriter{
Writer: &zerolog.LevelWriterAdapter{os.Stderr},
Level: level,
})
// add another writer to write to a log file
if logPath != "" {
LogFile, err = os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0664)
if err != nil {
return fmt.Errorf("failed to open log file: %v", err)
}
// add another write to write to the specified log file
writers = append(writers, &zerolog.FilteredLevelWriter{
Writer: zerolog.LevelWriterAdapter{LogFile},
Level: level,
})
}
writer = zerolog.MultiLevelWriter(writers...)
logger = zerolog.New(writer).Level(level).With().Timestamp().Caller().Logger()
zerolog.SetGlobalLevel(level)
log.Logger = logger
return nil
}
func strToLogLevel(ll LogLevel) (zerolog.Level, error) {
var tostr = func(lls []LogLevel) []string {
s := []string{}
for _, l := range lls {
s = append(s, string(l))
}
return s
}
if index := slices.Index(Levels[:], ll); index >= 0 {
// handle special cases to map index to DISABLED and TRACE
switch index {
case 4:
return zerolog.Disabled, nil
case 5:
return zerolog.TraceLevel, nil
}
return zerolog.Level(index), nil
}
return -100, fmt.Errorf(
"invalid log level (options: %s)", strings.Join(tostr(Levels[:]), ", "),
) // use 'info' by default
}

52
pkg/models.go Normal file
View file

@ -0,0 +1,52 @@
package makeshift
import (
"git.towk2.me/towk/makeshift/pkg/storage"
)
type ProfileMap map[string]*Profile
type Profile struct {
ID string `json:"id"` // profile ID
Description string `json:"description,omitempty"` // profile description
Tags []string `json:"tags,omitempty"` // tags used for ...
Data map[string]any `json:"data,omitempty"` // include render data
}
type Plugin interface {
Name() string
Version() string
Description() string
Metadata() Metadata
Init() error
Run(data storage.KVStore, args []string) error
Cleanup() error
}
type Metadata map[string]any
type Hook struct {
Data storage.KVStore
Args []string
Plugin Plugin
}
func (h *Hook) Init() error {
return h.Plugin.Init()
}
func (h *Hook) Run() error {
return h.Plugin.Run(h.Data, h.Args)
}
func (h *Hook) Cleanup() error {
return h.Plugin.Cleanup()
}
func PluginToMap(p Plugin) map[string]any {
return map[string]any{
"name": p.Name(),
"version": p.Version(),
"description": p.Description(),
"metadata": p.Metadata(),
}
}

View file

@ -0,0 +1,128 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
makeshift "git.towk2.me/towk/makeshift/pkg"
"git.towk2.me/towk/makeshift/pkg/storage"
"github.com/nikolalohinski/gonja/v2"
"github.com/nikolalohinski/gonja/v2/exec"
"github.com/rs/zerolog/log"
)
type Jinja2 struct{}
func (p *Jinja2) Name() string { return "jinja2" }
func (p *Jinja2) Version() string { return "v0.0.1-alpha" }
func (p *Jinja2) Description() string { return "Renders Jinja 2 templates" }
func (p *Jinja2) Metadata() makeshift.Metadata {
return makeshift.Metadata{
"author": map[string]any{
"name": "David J. Allen",
"email": "davidallendj@gmail.com",
"links": []string{
"https://github.com/davidallendj",
"https://git.towk2.me/towk",
},
},
}
}
func (p *Jinja2) Init() error {
// nothing to initialize
log.Debug().Str("plugin", p.Name()).Msg("jinja2.Init()")
return nil
}
func (p *Jinja2) Run(store storage.KVStore, args []string) error {
// render the files using Jinja 2 from args
var (
mappings struct {
Data map[string]any `json:"data"`
}
context *exec.Context
template *exec.Template
profiles any // makeshift.ProfileMap
input any // []byte
output bytes.Buffer
err error
)
log.Debug().
Str("plugin", p.Name()).
Any("store", store).
Strs("args", args).
Int("arg_count", len(args)).
Msg("(jinja2) Run()")
profiles, err = store.Get("profiles")
if err != nil {
return fmt.Errorf("(jinja2) failed to get profiles: %v", err)
}
input, err = store.Get("file")
if err != nil {
return fmt.Errorf("(jinja2) failed to get input data: %v", err)
}
// get the templates provided as args to the plugin
template, err = gonja.FromBytes(input.([]byte))
if err != nil {
return fmt.Errorf("(jinja2) failed to get template from args: %v", err)
}
// get mappings from shared data (optional)
shared, err := store.Get("shared")
if err != nil {
log.Warn().Err(err).Msg("(jinja2) could not retrieve shared data")
} else {
err = json.Unmarshal(shared.([]byte), &mappings)
if err != nil {
return fmt.Errorf("(jinja2) failed to unmarshal mappings from shared data: %v", err)
}
}
var ps = make(map[string]any)
for profileID, profile := range profiles.(makeshift.ProfileMap) {
ps[profileID] = map[string]any{
"id": profile.ID,
"description": profile.Description,
"data": profile.Data,
}
}
// inject profiles and plugin-specific mapping
mappings.Data = map[string]any{
"makeshift": map[string]any{
"profiles": ps,
"plugin": map[string]any{
"name": p.Name(),
"version": p.Version(),
"description": p.Description(),
"metadata": p.Metadata(),
},
},
}
log.Debug().Any("mappings", mappings).Send()
// use the provided data in the store to render templates
// NOTE: this may be changed to specifically use "shared" data instead
context = exec.NewContext(mappings.Data)
if err = template.Execute(&output, context); err != nil { // Prints: Hello Bob!
return fmt.Errorf("(jinja2) failed to render template: %v", err)
}
// write render templates to data store output
store.Set("out", output.Bytes())
return nil
}
func (p *Jinja2) Cleanup() error {
// nothing to clean up
log.Debug().Str("plugin", p.Name()).Msg("(jinja2) Cleanup()")
return nil
}
var Makeshift Jinja2

View file

@ -0,0 +1,30 @@
package main
import "git.towk2.me/towk/makeshift/pkg/storage"
type Mapper struct{}
func (p *Mapper) Name() string { return "jinja2" }
func (p *Mapper) Version() string { return "test" }
func (p *Mapper) Description() string { return "Renders Jinja 2 templates" }
func (p *Mapper) Metadata() map[string]string {
return map[string]string{
"author.name": "David J. Allen",
"author.email": "davidallendj@gmail.com",
}
}
func (p *Mapper) Init() error {
// nothing to initialize
return nil
}
func (p *Mapper) Run(data storage.KVStore, args []string) error {
return nil
}
func (p *Mapper) Clean() error {
return nil
}
var Makeshift Mapper

279
pkg/plugins/smd/smd.go Normal file
View file

@ -0,0 +1,279 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
makeshift "git.towk2.me/towk/makeshift/pkg"
"git.towk2.me/towk/makeshift/pkg/storage"
"github.com/rs/zerolog/log"
)
// 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:"-" yaml:"-"`
Host string `yaml:"host"`
Port int `yaml:"port"`
AccessToken string `yaml:"access-token"`
RedfishEndpoints []RedfishEndpoint `json:"redfish_endpoints"`
EthernetInterfaces []EthernetInterface `json:"ethernet_interfaces"`
Components []Component `json:"components"`
}
type IPAddr struct {
IpAddress string `json:"IPAddress"`
Network string `json:"Network"`
}
type EthernetInterface struct {
ID string `json:"ID"`
Description string `json:"Description"`
MacAddress string `json:"MACAddr"`
LastUpdate string `json:"LastUpdate"`
ComponentID string `json:"ComponentID"`
Type string `json:"Type"`
IpAddresses []IPAddr `json:"IPAddresses"`
}
type Component struct {
ID string `json:"ID"`
Type string `json:"Type"`
State string `json:"State,omitempty"`
Flag string `json:"Flag,omitempty"`
Enabled *bool `json:"Enabled,omitempty"`
SwStatus string `json:"SoftwareStatus,omitempty"`
Role string `json:"Role,omitempty"`
SubRole string `json:"SubRole,omitempty"`
NID json.Number `json:"NID,omitempty"`
Subtype string `json:"Subtype,omitempty"`
NetType string `json:"NetType,omitempty"`
Arch string `json:"Arch,omitempty"`
Class string `json:"Class,omitempty"`
ReservationDisabled bool `json:"ReservationDisabled,omitempty"`
Locked bool `json:"Locked,omitempty"`
}
type RedfishEndpoint struct {
ID string `json:"ID"`
Type string `json:"Type"`
Name string `json:"Name,omitempty"` // user supplied descriptive name
Hostname string `json:"Hostname"`
Domain string `json:"Domain"`
FQDN string `json:"FQDN"`
Enabled bool `json:"Enabled"`
UUID string `json:"UUID,omitempty"`
User string `json:"User"`
Password string `json:"Password"` // Temporary until more secure method
UseSSDP bool `json:"UseSSDP,omitempty"`
MACRequired bool `json:"MACRequired,omitempty"`
MACAddr string `json:"MACAddr,omitempty"`
IPAddr string `json:"IPAddress,omitempty"`
}
func (p *SmdClient) Name() string { return "smd" }
func (p *SmdClient) Version() string { return "v0.0.1-alpha" }
func (p *SmdClient) Description() string { return "Fetchs data from SMD and writes to store" }
func (p *SmdClient) Metadata() makeshift.Metadata {
return makeshift.Metadata{
"author": map[string]any{
"name": "David J. Allen",
"email": "davidallendj@gmail.com",
"links": []string{
"https://github.com/davidallendj",
"https://git.towk2.me/towk",
},
},
}
}
func (p *SmdClient) Init() error {
log.Debug().Str("plugin", p.Name()).Msg("(smd) Init()")
return nil
}
func (p *SmdClient) Run(store storage.KVStore, args []string) error {
// set all the defaults for variables
var (
client SmdClient
bytes []byte
err error
)
log.Debug().
Str("plugin", p.Name()).
Strs("args", args).
Int("arg_count", len(args)).
Any("store", store).
Msg("(smd) Run()")
// if we have a client, try making the request for the ethernet interfaces
err = client.FetchEthernetInterfaces()
if err != nil {
return fmt.Errorf("(smd) failed to fetch ethernet interfaces with client: %v", err)
}
err = client.FetchRedfishEndpoints()
if err != nil {
return fmt.Errorf("(smd) failed to fetch redfish endpoints with client: %v", err)
}
err = client.FetchComponents()
if err != nil {
return fmt.Errorf("(smd) failed to fetch components with client: %v", err)
}
// write data back to shared data store to be used by other plugins
bytes, err = json.Marshal(client)
if err != nil {
return fmt.Errorf("(smd) failed to marshal SMD client: %v", err)
}
store.Set("shared", bytes)
// apply template substitutions and return output as byte array
return nil
}
func (p *SmdClient) Cleanup() error {
log.Debug().Str("plugin", p.Name()).Msg("(smd) Init()")
return nil
}
// 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() error {
var (
bytes []byte
err error
)
// make request to SMD endpoint
bytes, err = client.makeRequest("/Inventory/EthernetInterfaces")
if err != nil {
return fmt.Errorf("failed to read HTTP response: %v", err)
}
// unmarshal response body JSON and extract in object
err = json.Unmarshal(bytes, &client.EthernetInterfaces)
if err != nil {
return fmt.Errorf("failed to unmarshal response: %v", err)
}
// print what we got if verbose is set
log.Debug().Str("ethernet_interfaces", string(bytes)).Msg("found interfaces")
return nil
}
// 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() error {
var (
bytes []byte
err error
)
// make request to SMD endpoint
bytes, err = client.makeRequest("/State/Components")
if err != nil {
return fmt.Errorf("failed to make HTTP request: %v", err)
}
// make sure our response is actually JSON first
if !json.Valid(bytes) {
return 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(bytes, &tmp)
if err != nil {
return fmt.Errorf("failed to unmarshal response: %v", err)
}
bytes, err = json.Marshal(tmp["RedfishEndpoints"].([]any))
if err != nil {
return fmt.Errorf("failed to marshal JSON: %v", err)
}
err = json.Unmarshal(bytes, &client.Components)
if err != nil {
return fmt.Errorf("failed to unmarshal response: %v", err)
}
// print what we got if verbose is set
log.Debug().Str("components", string(bytes)).Msg("found components")
return nil
}
// TODO: improve implementation of this function
func (client *SmdClient) FetchRedfishEndpoints() error {
var (
store map[string]any
rfeps []RedfishEndpoint
body []byte
err error
)
// make initial request to get JSON with 'RedfishEndpoints' as property
body, err = client.makeRequest("/Inventory/RedfishEndpoints")
if err != nil {
return fmt.Errorf("failed to make HTTP resquest: %v", err)
}
// make sure response is in JSON
if !json.Valid(body) {
return fmt.Errorf("expected valid JSON response: %s", string(body))
}
err = json.Unmarshal(body, &store)
if err != nil {
return fmt.Errorf("failed to unmarshal response: %v", err)
}
// marshal RedfishEndpoint JSON back to makeshift.RedfishEndpoint
body, err = json.Marshal(store["RedfishEndpoints"].([]any))
if err != nil {
return fmt.Errorf("failed to marshal JSON: %v", err)
}
err = json.Unmarshal(body, &rfeps)
if err != nil {
return fmt.Errorf("failed to unmarshal response: %v", err)
}
// show the final result
log.Debug().Bytes("redfish_endpoints", body).Msg("found redfish endpoints")
client.RedfishEndpoints = rfeps
return nil
}
func (client *SmdClient) makeRequest(endpoint string) ([]byte, error) {
if client == nil {
return nil, fmt.Errorf("client is nil")
}
// fetch DHCP related information from SMD's 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)
}
// include access token in authorzation header if found
// NOTE: This shouldn't be needed for this endpoint since it's public
if client.AccessToken != "" {
req.Header.Add("Authorization", "Bearer "+client.AccessToken)
}
// make the request to SMD
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %v", err)
}
// read the contents of the response body
return io.ReadAll(res.Body)
}
var Makeshift SmdClient

View file

@ -1,327 +0,0 @@
//go:build server || all
// +build server all
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"
openchami_authenticator "github.com/openchami/chi-middleware/auth"
openchami_logger "github.com/openchami/chi-middleware/log"
"github.com/rs/zerolog/log"
)
var (
tokenAuth *jwtauth.JWTAuth = nil
)
type Jwks struct {
Uri string
Retries int
}
type Server struct {
*http.Server
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(conf *config.Config) *Server {
// create default config if none supplied
if conf == nil {
c := config.New()
conf = &c
}
newServer := &Server{
Config: conf,
Server: &http.Server{Addr: conf.Server.Host},
Jwks: Jwks{
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 {
// Setup logger
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
logger := log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
// set the server address with config values
s.Server.Addr = s.Config.Server.Host
// fetch JWKS public key from authorization server
if s.Config.Server.Jwks.Uri != "" && tokenAuth == nil {
for i := 0; i < s.Config.Server.Jwks.Retries; i++ {
var err error
tokenAuth, err = configurator.FetchPublicKeyFromURL(s.Config.Server.Jwks.Uri)
if err != nil {
log.Error().Err(err).Msgf("failed to fetch JWKS")
continue
}
break
}
}
// 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()
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(middleware.Logger)
router.Use(middleware.Recoverer)
router.Use(middleware.StripSlashes)
router.Use(middleware.Timeout(60 * time.Second))
router.Use(openchami_logger.OpenCHAMILogger(logger))
if s.Config.Server.Jwks.Uri != "" {
router.Group(func(r chi.Router) {
r.Use(
jwtauth.Verifier(tokenAuth),
openchami_authenticator.AuthenticatorWithRequiredClaims(tokenAuth, []string{"sub", "iss", "aud"}),
)
// protected routes if using auth
r.HandleFunc("/generate", s.Generate(opts...))
r.Post("/targets", s.createTarget)
})
} else {
// public routes without auth
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()
}
// TODO: implement a way to shut the server down
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(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
}
// 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: %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
}
}
}
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 {
fmt.Printf("failed to encode JSON: %v\n", err)
return
}
}
// 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) 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...)
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
}

77
pkg/service/constants.go Normal file
View file

@ -0,0 +1,77 @@
package service
const (
RELPATH_PLUGINS = "/plugins"
RELPATH_PROFILES = "/profiles"
RELPATH_DATA = "/data"
RELPATH_METADATA = "/.makeshift"
RELPATH_HELP = RELPATH_DATA + "/index.html"
RELPATH_PROFILE = RELPATH_PROFILES + "/default.json"
PATH_CONFIG = "$HOME/.config/makeshift/config.yaml"
DEFAULT_TIMEOUT_IN_SECS = 60
DEFAULT_PLUGINS_MAX_COUNT = 32
DEFAULT_PROFILES_MAX_COUNT = 256
FILE_METADATA = ``
FILE_HOME_PAGE = `
<!DOCTYPE html>
<html>
<body>
<p>
Plugin Information:
Name: {{ makeshift.plugin.name }}
Version: {{ makeshift.plugin.version }}
Description: {{ makeshift.plugin.description }}
Author: {{ makeshift.plugin.metadata.name }} ({{ makeshift.plugin.metadata.email }})
Profile Information:
ID: {{ makeshift.profiles.default.id }}
Description: {{ makeshift.profiles.default.description }}
# setup environment variables</br>
export MAKESHIFT_HOST={{ makeshift.profiles.default.data.host }}</br>
export MAKESHIFT_PATH={{ makeshift.profiles.default.data.path }}</br>
export MAKESHIFT_ROOT={{ makeshift.profiles.default.data.server_root }}</br></br>
# start the service</br>
makeshift serve --root ./tests --init -l debug</br></br>
# download a file or directory (as archive)</br>
makeshift download</br>
makeshift download --host http://localhost:5050 --path help.txt</br></br>
# download files with rendering using plugins</br>
makeshift download --plugins smd,jinja2 --profile compute</br>
makeshift download -p templates --plugins jinja --profile io</br>
curl $MAKESHIFT_HOST/download/help.txt?plugins=smd,jinja2</br></br>
# upload a file or directory (recursively)</br>
makeshift upload</br>
makeshift upload --host http://localhost:5050 --path help.txt</br></br>
# list the files in a directory</br>
makeshift list --path help.txt</br>
makeshift list --host http://localhost:5050 --path help.txt</br>
curl http://localhost:5050/list/test</br>
</p>
<body>
</html>
`
FILE_DEFAULT_PROFILE = `
{
"id": "default",
"description": "Makeshift default profile",
"data": {
"host": "localhost",
"path": "/test",
"server_root": "./test"
}
}
`
)
// makeshift.host: https://localhost:5050
// makeshift.path: test
// makeshift.server.root: $HOME/apps/makeshift

141
pkg/service/plugins.go Normal file
View file

@ -0,0 +1,141 @@
package service
import (
"encoding/json"
"io"
"net/http"
"os"
makeshift "git.towk2.me/towk/makeshift/pkg"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
func (s *Service) ListPlugins() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
plugins map[string]makeshift.Plugin
names []string
body []byte
err error
)
plugins, err = LoadPluginsFromDir(s.PathForPlugins())
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for name := range plugins {
names = append(names, name)
}
body, err = json.Marshal(names)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(body)
}
}
func (s *Service) GetPluginInfo() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
pluginName = chi.URLParam(r, "name")
path = s.PathForPluginWithName(pluginName)
plugin makeshift.Plugin
body []byte
err error
)
plugin, err = LoadPluginFromFile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
body, err = json.Marshal(makeshift.PluginToMap(plugin))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(body)
}
}
func (s *Service) GetPluginRaw() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
pluginName = chi.URLParam(r, "name")
path = s.PathForPluginWithName(pluginName)
rawPlugin []byte
err error
)
rawPlugin, err = os.ReadFile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(rawPlugin)
}
}
func (s *Service) CreatePlugin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
pluginName = chi.URLParam(r, "name")
body []byte
path string
err error
)
// helper to check for valid plugin name
var hasValidName = func(name string) bool {
return name != "" && len(name) < 64
}
// check for a valid plugin name
if !hasValidName(pluginName) {
http.Error(w, "invalid name for plugin", http.StatusBadRequest)
return
}
body, err = io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
path = s.PathForPluginWithName(pluginName)
err = os.WriteFile(path, body, 0o777)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
}
func (s *Service) DeletePlugin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
pluginName = chi.URLParam(r, "name")
path = s.PathForPluginWithName(pluginName)
err error
)
log.Debug().Str("path", path).Send()
err = os.Remove(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}

278
pkg/service/profiles.go Normal file
View file

@ -0,0 +1,278 @@
package service
import (
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
makeshift "git.towk2.me/towk/makeshift/pkg"
"github.com/go-chi/chi/v5"
"github.com/tidwall/sjson"
)
func (s *Service) ListProfiles() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
path = s.RootPath + RELPATH_PROFILES
profiles []*makeshift.Profile
contents []byte
err error
)
// walk profiles directory to load all profiles
err = filepath.Walk(path, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// skip directories
if info.IsDir() {
return nil
}
// read file contents
var profile *makeshift.Profile
profile, err = LoadProfileFromFile(path)
if err != nil {
return err
}
profiles = append(profiles, profile)
fmt.Println(path, info.Size())
return nil
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
// marshal and send all the profiles
contents, err = json.Marshal(profiles)
if err != nil {
http.Error(w, fmt.Sprintf("failed to marshal profiles: %v", err), http.StatusInternalServerError)
}
_, err = w.Write(contents)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func (s *Service) GetProfile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
id = chi.URLParam(r, "id")
path = s.PathForProfileWithID(id)
contents []byte
err error
)
contents, err = loadProfileContents(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
_, err = w.Write(contents)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func (s *Service) CreateProfile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
body, contents []byte
path string
profile *makeshift.Profile
err error
)
body, err = io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// use the request info to build profile
err = json.Unmarshal(body, &profile)
if err != nil {
http.Error(w, fmt.Sprintf("failed to unmarshal profile: %v", err.Error()), http.StatusBadRequest)
return
}
// serialize just the profile part
contents, err = json.Marshal(profile)
if err != nil {
http.Error(w, fmt.Sprintf("failed to marshal profile: %v", err.Error()), http.StatusBadRequest)
return
}
// create a new profile on disk
path = s.PathForProfileWithID(profile.ID)
err = os.WriteFile(path, contents, os.ModePerm)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
func (s *Service) DeleteProfile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
profileID = chi.URLParam(r, "id")
path string
err error
)
if profileID == "default" {
http.Error(w, "cannot delete the default profile", http.StatusBadRequest)
return
}
path = s.PathForProfileWithID(profileID)
err = os.Remove(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
func (s *Service) SetProfileData() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
body, contents []byte
newContents string
profile *makeshift.Profile
path string
err error
)
body, err = io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.Unmarshal(body, &profile)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// make sure the request data sets an ID
if profile.ID == "" {
http.Error(w, "ID must be set to a non-empty value", http.StatusBadRequest)
return
}
// read the contents the file with profile ID
path = s.PathForProfileWithID(profile.ID)
contents, err = os.ReadFile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// modify the data of the profile's contents
newContents, err = sjson.Set(string(contents), "data", profile.Data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// write only the data to the file with ID
err = os.WriteFile(path, []byte(newContents), os.ModePerm)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func (s *Service) DeleteProfileData() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
id = chi.URLParam(r, "id")
path = s.PathForProfileWithID(id)
profile *makeshift.Profile
err error
)
// get the profile
profile, err = LoadProfileFromFile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// delete the profile data
profile.Data = map[string]any{}
// save the profile back to the file to update
SaveProfileToFile(path, profile)
}
}
func (s *Service) GetProfileData() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
id = chi.URLParam(r, "id")
path = s.PathForProfileWithID(id)
profile *makeshift.Profile
body []byte
err error
)
profile, err = LoadProfileFromFile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// only marshal the profile data and not entire profile
body, err = json.Marshal(profile.Data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// write body to response
_, err = w.Write(body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func loadProfileContents(path string) ([]byte, error) {
var (
contents []byte
profile *makeshift.Profile
err error
)
profile, err = LoadProfileFromFile(path)
if err != nil {
return nil, fmt.Errorf("failed to load profile from file: %v", err)
}
contents, err = json.Marshal(profile)
if err != nil {
return nil, fmt.Errorf("failed to marshal profile: %v", err)
}
return contents, nil
}

376
pkg/service/routes.go Normal file
View file

@ -0,0 +1,376 @@
package service
import (
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"git.towk2.me/towk/makeshift/internal/archive"
makeshift "git.towk2.me/towk/makeshift/pkg"
"git.towk2.me/towk/makeshift/pkg/storage"
"github.com/rs/zerolog/log"
)
func (s *Service) Download() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
path = s.PathForData() + strings.TrimPrefix(r.URL.Path, "/download")
pluginNames = strings.Split(r.URL.Query().Get("plugins"), ",")
profileIDs = strings.Split(r.URL.Query().Get("profiles"), ",")
fileInfo os.FileInfo
out *os.File
store *storage.MemoryStorage = new(storage.MemoryStorage)
hooks []makeshift.Hook
contents []byte
errs []error
err error
)
// initialize storage
store.Init()
log.Debug().
Str("path", path).
Str("client_host", r.Host).
Strs("plugins", pluginNames).
Strs("profiles", profileIDs).
Any("query", r.URL.Query()).
Msg("Service.Download()")
// prepare profiles
errs = s.loadProfiles(profileIDs, store, errs)
if len(errs) > 0 {
log.Error().Errs("errs", errs).Msg("errors occurred loading profiles")
errs = []error{}
}
// determine if path is directory, file, or exists
if fileInfo, err = os.Stat(path); err == nil {
if fileInfo.IsDir() {
// get the final archive path
archivePath := fmt.Sprintf("%s.tar.gz", path)
log.Debug().
Str("archive_path", archivePath).
Str("type", "directory").
Msg("Service.Download()")
out, err = os.Create(archivePath)
if err != nil {
s.writeErrorResponse(w, fmt.Sprintf("failed to create named file: %v", err), http.StatusInternalServerError)
return
}
// get a list of filenames to archive
filenamesToArchive := []string{}
filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
filenamesToArchive = append(filenamesToArchive, path)
}
return nil
})
log.Debug().Strs("files", filenamesToArchive).Send()
// prepare plugins
hooks, errs = s.loadPlugins(pluginNames, store, nil, errs)
if len(errs) > 0 {
log.Error().Errs("errs", errs).Msg("errors occurred loading plugins")
errs = []error{}
}
// create an archive of the directory, run hooks, and download
err = archive.Create(filenamesToArchive, out, hooks)
if err != nil {
s.writeErrorResponse(w, fmt.Sprintf("failed to create archive: %v", err.Error()), http.StatusInternalServerError)
return
}
// load the final archive
contents, err = os.ReadFile(archivePath)
if err != nil {
s.writeErrorResponse(w, fmt.Sprintf("failed to read archive contents: %v", err.Error()), http.StatusInternalServerError)
return
}
// send the archive back as response
w.Header().Add("FILETYPE", "archive")
w.Write(contents)
// clean up the temporary archive
err = os.Remove(archivePath)
if err != nil {
log.Error().Err(err).Msg("failed to remove temporary archive")
return
}
} else {
// download individual file
log.Debug().
Str("type", "file").
Msg("Service.Download()")
contents, err = os.ReadFile(path)
if err != nil {
s.writeErrorResponse(w, fmt.Sprintf("failed to read file to download: %v", err), http.StatusInternalServerError)
return
}
// prepare plugins
store.Set("file", contents)
hooks, errs = s.loadPlugins(pluginNames, store, nil, errs)
if len(errs) > 0 {
log.Error().Errs("errs", errs).Msg("errors occurred loading plugins")
errs = []error{}
}
if len(hooks) > 0 {
// run pre-hooks to modify the contents of the file before archiving
log.Debug().Int("hook_count", len(hooks)).Msg("running hooks")
for _, hook := range hooks {
log.Debug().Any("hook", map[string]any{
"store": hook.Data,
"args": hook.Args,
"plugin": map[string]string{
"name": hook.Plugin.Name(),
"description": hook.Plugin.Description(),
"version": hook.Plugin.Version(),
},
}).Send()
err = hook.Init()
if err != nil {
log.Error().
Err(err).
Str("plugin", hook.Plugin.Name()).
Msg("failed to initialize plugin")
continue
}
err = hook.Run()
if err != nil {
log.Error().
Err(err).
Str("plugin", hook.Plugin.Name()).
Msg("failed to run plugin")
continue
}
err = hook.Cleanup()
if err != nil {
log.Error().
Err(err).
Str("plugin", hook.Plugin.Name()).
Msg("failed to cleanup plugin")
continue
}
}
// take the contents from the last hook and update files
var (
hook = hooks[len(hooks)-1]
data any
)
data, err = hook.Data.Get("out")
if err != nil {
s.writeErrorResponse(w, fmt.Sprintf("failed to get data from hook: %v", err), http.StatusInternalServerError)
return
}
// send processed (with plugins) file back as response
w.Header().Add("FILETYPE", "file")
w.Write(data.([]byte))
} else {
// send non-processed file back as response
w.Header().Add("FILETYPE", "file")
w.Write(contents)
}
}
} else {
s.writeErrorResponse(w, err.Error(), http.StatusBadRequest)
return
}
}
}
func (s *Service) Upload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
path = s.PathForData() + strings.TrimPrefix(r.URL.Path, "/upload")
body []byte
dirpath string
err error
)
// show what we're uploading
log.Debug().
Str("path", path).
Msg("Service.Upload()")
// take the provided path and store the file contents
dirpath = filepath.Dir(path)
err = os.MkdirAll(dirpath, 0o777)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// write file to disk
body, err = io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = os.WriteFile(path, body, 0o777)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
func (s *Service) List() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
path = s.PathForData() + strings.TrimPrefix(r.URL.Path, "/list")
entries []string
body []byte
err error
)
// show what we're listing
log.Debug().Str("path", path).Msg("Service.List()")
// walk directory and show all entries "ls"
err = filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
entries = append(entries, d.Name())
return nil
})
if err != nil {
switch err {
case fs.ErrNotExist, fs.ErrInvalid:
http.Error(w, "No such file or directory...", http.StatusBadRequest)
case fs.ErrPermission:
http.Error(w, "Invalid permissions...", http.StatusForbidden)
default:
http.Error(w, "Something went wrong (file or directory *probably* does not exist)...", http.StatusInternalServerError)
}
return
}
body, err = json.Marshal(entries)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(body)
}
}
func (s *Service) Delete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
path = s.PathForData() + strings.TrimPrefix(r.URL.Path, "/delete")
err error
)
err = os.RemoveAll(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
func (s *Service) GetStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(map[string]any{
"code": http.StatusOK,
"message": "The makeshift server is healthy",
})
if err != nil {
fmt.Printf("failed to encode JSON response body: %v\n", err)
return
}
}
func (s *Service) loadProfiles(profileIDs []string, store storage.KVStore, errs []error) []error {
// load data from profiles into the data store
var profiles = make(makeshift.ProfileMap, len(profileIDs))
for i, profileID := range profileIDs {
var (
profilePath = s.PathForProfileWithID(profileID)
profile *makeshift.Profile
err error
)
if i > s.ProfilesMaxCount {
log.Warn().Msg("max profiles count reached...stopping")
return errs
}
if profileID == "" {
log.Warn().Msg("profile ID is empty...skipping")
continue
}
log.Debug().
Str("id", profileID).
Str("path", profilePath).
Msg("load profile")
profile, err = LoadProfileFromFile(profilePath)
if err != nil {
errs = append(errs, err)
continue
}
profiles[profileID] = profile
}
store.Set("profiles", profiles)
return errs
}
func (s *Service) loadPlugins(pluginNames []string, store storage.KVStore, args []string, errs []error) ([]makeshift.Hook, []error) {
// create hooks to run from provided plugins specified
var hooks []makeshift.Hook
for i, pluginName := range pluginNames {
var (
pluginPath string = s.PathForPluginWithName(pluginName)
plugin makeshift.Plugin
err error
)
if i > s.PluginsMaxCount {
log.Warn().Msg("max plugins count reached or exceeded...stopping")
return hooks, errs
}
if pluginName == "" {
log.Warn().Msgf("no plugin name found with index %d...skipping", i)
continue
}
log.Debug().
Str("name", pluginName).
Str("path", pluginPath).
Msg("load plugin")
// load the plugin from disk
plugin, err = LoadPluginFromFile(pluginPath)
if err != nil {
errs = append(errs, err)
continue
}
hooks = append(hooks, makeshift.Hook{
Data: store,
Args: args,
Plugin: plugin,
})
}
return hooks, errs
}

292
pkg/service/service.go Normal file
View file

@ -0,0 +1,292 @@
package service
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"plugin"
"slices"
"time"
makeshift "git.towk2.me/towk/makeshift/pkg"
"git.towk2.me/towk/makeshift/pkg/util"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog/log"
)
type Service struct {
Addr string
RootPath string `yaml:"root,omitempty"`
CACertFile string `yaml:"cacert,omitempty"`
CACertKeyfile string `yaml:"keyfile,omitempty"`
// max counts
PluginsMaxCount int
ProfilesMaxCount int
Timeout time.Duration
}
// New creates a new Service instance with default values
func New() *Service {
return &Service{
Addr: ":5050",
RootPath: "./",
PluginsMaxCount: DEFAULT_PLUGINS_MAX_COUNT,
ProfilesMaxCount: DEFAULT_PROFILES_MAX_COUNT,
Timeout: DEFAULT_TIMEOUT_IN_SECS,
}
}
// Init() sets up the default files and directories for the service
func (s *Service) Init() error {
// create the default directories
var err error
err = os.MkdirAll(s.RootPath, 0o777)
if err != nil {
return fmt.Errorf("failed to make service root path: %v", err)
}
err = os.MkdirAll(s.PathForPlugins(), 0o777)
if err != nil {
return fmt.Errorf("failed to make service plugin path: %v", err)
}
err = os.MkdirAll(s.PathForProfiles(), 0o777)
if err != nil {
return fmt.Errorf("failed to make service profile path: %v", err)
}
err = os.MkdirAll(s.PathForData(), 0o777)
if err != nil {
return fmt.Errorf("failed to make service data path: %v", err)
}
// create the default files
err = os.WriteFile(s.PathForMetadata(), []byte(FILE_METADATA), 0o777)
if err != nil {
return fmt.Errorf("failed to make service metadata file: %v", err)
}
err = os.WriteFile(s.PathForHome(), []byte(FILE_HOME_PAGE), 0o777)
if err != nil {
return fmt.Errorf("failed to make service home page file: %v", err)
}
err = os.WriteFile(s.PathForProfileWithID("default"), []byte(FILE_DEFAULT_PROFILE), 0o777)
if err != nil {
return fmt.Errorf("failed to make service default profile file: %v", err)
}
return nil
}
// Serve() starts the makeshift service and waits for requests.
func (s *Service) Serve() error {
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(middleware.Logger)
router.Use(middleware.Recoverer)
router.Use(middleware.StripSlashes)
router.Use(middleware.Timeout(s.Timeout * time.Second))
if s.requireAuth() {
} else {
// general
router.Get("/download/*", s.Download())
router.Post("/upload/*", s.Upload())
router.Get("/list/*", s.List())
router.Delete("/delete/*", s.Delete())
// profiles
router.Get("/profiles", s.ListProfiles())
router.Get("/profiles/{id}", s.GetProfile())
router.Post("/profiles/{id}", s.CreateProfile())
router.Delete("/profiles/{id}", s.DeleteProfile())
router.Get("/profiles/{id}/data", s.GetProfileData())
router.Post("/profiles/{id}/data", s.SetProfileData())
router.Delete("/profiles/{id}/data", s.DeleteProfileData())
// plugins
router.Get("/plugins", s.ListPlugins())
router.Get("/plugins/{name}/info", s.GetPluginInfo())
router.Get("/plugins/{name}/raw", s.GetPluginRaw())
router.Post("/plugins/{name}", s.CreatePlugin())
router.Delete("/plugins/{name}", s.DeletePlugin())
}
// always available public routes go here
router.HandleFunc("/status", s.GetStatus)
if s.CACertFile != "" && s.CACertKeyfile != "" {
return http.ListenAndServeTLS(s.Addr, s.CACertFile, s.CACertKeyfile, router)
} else {
return http.ListenAndServe(s.Addr, router)
}
}
func (s *Service) requireAuth() bool {
return false
}
func (s *Service) FetchJwks(uri string) {
}
func LoadProfileFromFile(path string) (*makeshift.Profile, error) {
return loadFromJSONFile[makeshift.Profile](path)
}
// LoadPluginFromFile loads a single plugin given a single file path
func LoadPluginFromFile(path string) (makeshift.Plugin, error) {
var (
isDir bool
err error
loadedPlugin *plugin.Plugin
)
// skip loading plugin if path is a directory with no error
isDir, err = util.IsDirectory(path)
if err != nil {
return nil, fmt.Errorf("failed to test if plugin path is directory: %v", err)
} else if isDir {
return nil, fmt.Errorf("path is a directory")
}
// try and open the plugin
loadedPlugin, err = plugin.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open plugin at path '%s': %v", path, err)
}
// load the "Target" symbol from plugin
symbol, err := loadedPlugin.Lookup("Makeshift")
if err != nil {
return nil, fmt.Errorf("failed to look up symbol at path '%s': %v", path, err)
}
// assert that the plugin is a valid makeshift.Plugin
target, ok := symbol.(makeshift.Plugin)
if !ok {
return nil, fmt.Errorf("failed to assert the correct symbol type at path '%s'", path)
}
return target, nil
}
// LoadPluginsFromDir loads all plugins in a given directory.
//
// Returns a map of plugins. Each plugin can be accessed by the name
// returned by the plugin.GetName() implemented.
func LoadPluginsFromDir(dirpath string) (map[string]makeshift.Plugin, error) {
// check if verbose option is supplied
var (
cps = make(map[string]makeshift.Plugin)
err error
)
// helper to check for valid extensions
var hasValidExt = func(path string) bool {
return slices.Contains([]string{".so", ".dylib", ".dll"}, filepath.Ext(path))
}
// walk all files in directory only loading *valid* plugins
err = filepath.Walk(dirpath, func(path string, info fs.FileInfo, err error) error {
// skip trying to load generator plugin if directory or error
// only try loading if file has .so extension
if info.IsDir() || err != nil || !hasValidExt(path) {
return nil
}
// load the plugin from current path
p, err := LoadPluginFromFile(path)
if err != nil {
return fmt.Errorf("failed to load plugin in directory '%s': %v", path, err)
}
// map each plugin by name for lookup
cps[p.Name()] = p
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory '%s': %v", dirpath, err)
}
return cps, nil
}
func SaveProfileToFile(path string, profile *makeshift.Profile) error {
return saveToJSONFile(path, profile)
}
func SavePluginToFile(path string, plugin *makeshift.Plugin) error {
return saveToJSONFile(path, plugin)
}
func loadFromJSONFile[T any](path string) (*T, error) {
var (
res *T
contents []byte
err error
)
contents, err = os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read file: %v", err)
}
err = json.Unmarshal(contents, &res)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal contents from JSON: %v", err)
}
return res, err
}
func saveToJSONFile[T any](path string, data T) error {
var (
contents []byte
err error
)
contents, err = json.Marshal(data)
if err != nil {
return fmt.Errorf("failed to marshal data to JSON: %v", err)
}
err = os.WriteFile(path, contents, os.ModePerm)
if err != nil {
return fmt.Errorf("failed to write JSON to file: %v", err)
}
return nil
}
func (s *Service) PathForProfileWithID(id string) string {
return s.RootPath + RELPATH_PROFILES + "/" + id + ".json"
}
func (s *Service) PathForPluginWithName(name string) string {
return s.RootPath + RELPATH_PLUGINS + "/" + name + ".so"
}
func (s *Service) PathForProfiles() string {
return s.RootPath + RELPATH_PROFILES + "/"
}
func (s *Service) PathForPlugins() string {
return s.RootPath + RELPATH_PLUGINS + "/"
}
func (s *Service) PathForData() string {
return s.RootPath + RELPATH_DATA
}
func (s *Service) PathForMetadata() string {
return s.RootPath + RELPATH_METADATA
}
func (s *Service) PathForHome() string {
return s.RootPath + RELPATH_HELP
}
func (s *Service) writeErrorResponse(w http.ResponseWriter, message string, code int) {
http.Error(w, message, code)
log.Error().Msg(message)
}

23
pkg/storage/disk.go Normal file
View file

@ -0,0 +1,23 @@
package storage
type DiskStorage struct{}
func (ds DiskStorage) Init() error {
return nil
}
func (ds DiskStorage) Cleanup() error {
return nil
}
func (ds DiskStorage) Get(k string) error {
return nil
}
func (ds DiskStorage) Set(k string, v any) error {
return nil
}
func (ds DiskStorage) GetData() any {
return nil
}

33
pkg/storage/memory.go Normal file
View file

@ -0,0 +1,33 @@
package storage
import "fmt"
type MemoryStorage struct {
Data map[string]any `json:"data"`
}
func (ms *MemoryStorage) Init() error {
ms.Data = map[string]any{}
return nil
}
func (ms *MemoryStorage) Cleanup() error {
return nil
}
func (ms *MemoryStorage) Get(k string) (any, error) {
v, ok := ms.Data[k]
if ok {
return v, nil
}
return nil, fmt.Errorf("value '%s' does not exist", k)
}
func (ms *MemoryStorage) Set(k string, v any) error {
ms.Data[k] = v
return nil
}
func (ms *MemoryStorage) GetData() any {
return ms.Data
}

19
pkg/storage/storage.go Normal file
View file

@ -0,0 +1,19 @@
package storage
type KVStore interface {
Init() error
Cleanup() error
Get(k string) (any, error)
Set(k string, v any) error
GetData() any
}
type KVStaticStore[T any] interface {
Init() error
Cleanup() error
Get(k string) (T, error)
Set(k string, v T) error
GetData() T
}

View file

@ -1,10 +1,8 @@
package util
import (
"archive/tar"
"bytes"
"cmp"
"compress/gzip"
"crypto/tls"
"fmt"
"io"
@ -99,64 +97,3 @@ 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
}

View file

@ -0,0 +1,11 @@
# download single file
GET http://localhost:5050/downloads
# download directory as archive
GET
# download single file using plugins
# download directory as archive using plugins

View file

@ -0,0 +1,8 @@
# upload a single new file
POST http://localhost:5050/upload
# upload a new directory
# upload a new plugin
# upload a new profile

View file

@ -0,0 +1,2 @@
GET http://localhost:5050/list
GET http://localhost:5050/status

View file

@ -0,0 +1,6 @@
GET http://localhost:5050/profiles
GET http://localhost:5050/profiles/test
POST http://localhost:5050/profiles/test
GET http://localhost:5050/profiles/test/data
POST http://localhost:5050/profiles/test/data
DELETE http://localhost:5050/profiles/test/data

View file

@ -0,0 +1,6 @@
GET http://localhost:5050/plugins
GET http://localhost:5050/plugin/test
POST http://localhost:5050/plugins/test
DELETE http://localhost:5050/plugin/test

View file

@ -1,22 +0,0 @@
##
## 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

@ -1,418 +0,0 @@
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{}
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 created for running tests."
}
func (g *TestGenerator) Generate(config *config.Config, params generator.Params) (generator.FileMap, error) {
// Jinja 2 template file
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.
`),
},
"test2": generator.Template{
Contents: []byte(`
This is another testing Jinja 2 template file using {{plugin_name}}.
`),
},
}
// apply Jinja templates to file
fileMap, err := generator.ApplyTemplates(generator.Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
}, files)
if err != nil {
return nil, fmt.Errorf("failed to apply templates: %v", err)
}
// make sure we have a valid config we can access
if config == nil {
return nil, fmt.Errorf("invalid config (config is nil)")
}
// TODO: make sure we can get a target
// make sure we have the same number of files in file list
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
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.FileMap{"test": []byte("test")}, nil
}
var Generator TestGenerator`)
)
// 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", 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)
// make temporary directory to test plugin
err = os.MkdirAll(testPluginDir, 0o777)
if err != nil {
t.Fatalf("failed to make temporary directory: %v", err)
}
// dump the plugin source code to a file
err = os.WriteFile(testPluginSourcePath, testPluginSource, os.ModePerm)
if err != nil {
t.Fatalf("failed to write test plugin file: %v", err)
}
// make sure the source file was actually written
fileInfo, err := os.Stat(testPluginSourcePath)
if err != nil {
t.Fatalf("failed to stat path: %v", err)
}
if fileInfo.IsDir() {
t.Fatalf("expected file but found directory")
}
// change to testing directory to run command
err = os.Chdir(testPluginDir)
if err != nil {
t.Fatalf("failed to 'cd' to temporary directory: %v", err)
}
// initialize the plugin directory as a Go project
cmd := exec.Command("bash", "-c", "go mod init github.com/OpenCHAMI/configurator-test-plugin")
if output, err := cmd.CombinedOutput(); err != nil {
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 {
t.Fatalf("failed to execute command: %v\n%s", err, string(output))
}
// execute command to build the plugin
cmd = exec.Command("bash", "-c", "go build -buildmode=plugin -o=test-plugin.so test-plugin.go")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to execute command: %v\n%s", err, string(output))
}
// stat the file to confirm that the plugin was built
fileInfo, err = os.Stat(testPluginPath)
if err != nil {
t.Fatalf("failed to stat plugin file: %v", err)
}
if fileInfo.IsDir() {
t.Fatalf("directory file but a file was expected")
}
if fileInfo.Size() <= 0 {
t.Fatal("found an empty file or file with size of 0 bytes")
}
// test loading plugins both individually and in a dir
gen, err := generator.LoadPlugin("test-plugin.so")
if err != nil {
t.Fatalf("failed to load the test plugin: %v", err)
}
// test that we have all expected methods with type assertions
if _, ok := gen.(interface {
GetName() string
GetVersion() string
GetDescription() string
Generate(*config.Config, generator.Params) (generator.FileMap, error)
}); !ok {
t.Error("plugin does not implement all of the generator interface")
}
// test loading plugins from a directory (should just load a single one)
gens, err := generator.LoadPlugins(testPluginDir)
if err != nil {
t.Fatalf("failed to load plugins in '%s': %v", testPluginDir, err)
}
// test all of the plugins loaded from a directory (should expect same result as above)
for _, gen := range gens {
if _, ok := gen.(interface {
GetName() string
GetVersion() string
GetDescription() string
Generate(*config.Config, generator.Params) (generator.FileMap, error)
}); !ok {
t.Error("plugin does not implement all of the generator interface")
}
}
}
// Test that expects to fail with a specific error using a partially
// implemented generator. The purpose of this test is to make sure we're
// seeing the correct error that we would expect in these situations.
// The errors should be something like:
// - no symbol: "failed to look up symbol at path"
// - invalid symbol: "failed to load the correct symbol type at path"
func TestPluginWithInvalidOrNoSymbol(t *testing.T) {
var (
testPluginDir = t.TempDir()
testPluginPath = fmt.Sprintf("%s/invalid-plugin.so", testPluginDir)
testPluginSourcePath = fmt.Sprintf("%s/invalid-plugin.go", testPluginDir)
testPluginSource = []byte(`
package main
// An invalid generator that does not or partially implements
// the "Generator" interface.
type InvalidGenerator struct{}
func (g *InvalidGenerator) GetName() string { return "invalid" }
var Generator InvalidGenerator
`)
)
// show all paths to make sure we're using the correct ones
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 {
t.Fatalf("failed to make temporary directory: %v", err)
}
// dump the plugin source code to a file
err = os.WriteFile(testPluginSourcePath, testPluginSource, os.ModePerm)
if err != nil {
t.Fatalf("failed to write test plugin file: %v", err)
}
// make sure the source file was actually written
fileInfo, err := os.Stat(testPluginSourcePath)
if err != nil {
t.Fatalf("failed to stat path: %v", err)
}
if fileInfo.IsDir() {
t.Fatalf("expected file but found directory")
}
// change to testing directory to run command
err = os.Chdir(testPluginDir)
if err != nil {
t.Fatalf("failed to 'cd' to temporary directory: %v", err)
}
// initialize the plugin directory as a Go project
cmd := exec.Command("bash", "-c", "go mod init github.com/OpenCHAMI/configurator-test-plugin")
if output, err := cmd.CombinedOutput(); err != nil {
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 {
t.Fatalf("failed to execute command: %v\n%s", err, string(output))
}
// execute command to build the plugin
cmd = exec.Command("bash", "-c", "go build -buildmode=plugin -o=invalid-plugin.so invalid-plugin.go")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to execute command: %v\n%s", err, string(output))
}
// stat the file to confirm that the plugin was built
fileInfo, err = os.Stat(testPluginPath)
if err != nil {
t.Fatalf("failed to stat plugin file: %v", err)
}
if fileInfo.IsDir() {
t.Fatalf("directory file but a file was expected")
}
if fileInfo.Size() <= 0 {
t.Fatal("found an empty file or file with size of 0 bytes")
}
// try and load plugin, but expect specific error
_, err = generator.LoadPlugin(testPluginSourcePath)
if err == nil {
t.Fatalf("expected an error, but returned nil")
}
}
// Test that expects to successfully "generate" a file using the built-in
// example plugin with no fetching.
//
// NOTE: Normally we would dynamically load a generator from a plugin, but
// we're not doing it here since that's not what is being tested.
func TestGenerateExample(t *testing.T) {
var (
conf = config.New()
gen = TestGenerator{}
)
// make sure our generator returns expected strings
t.Run("properties", func(t *testing.T) {
if gen.GetName() != "test" {
t.Error("test generator return unexpected name")
}
if gen.GetVersion() != "v1.0.0" {
t.Error("test generator return unexpected version")
}
if gen.GetDescription() != "This is a plugin created for running tests." {
t.Error("test generator return unexpected description")
}
})
// try to generate a file with templating applied
fileMap, err := gen.Generate(&conf, generator.Params{})
if err != nil {
t.Fatalf("failed to generate file: %v", err)
}
// test for 2 expected files to be generated in the output (hint: check the
// TestGenerator.Generate implementation)
if len(fileMap) != 2 {
t.Error("expected 2 files in generated output")
}
}
// Test that expects to successfully "generate" a file using the built-in
// example plugin but by making a HTTP request to a service instance instead.
//
// NOTE: This test uses the default server settings to run. Also, no need to
// try and load the plugin from a lib here either.
func TestGenerateExampleWithServer(t *testing.T) {
var (
conf = config.New()
gen = TestGenerator{}
headers = make(map[string]string, 0)
)
// 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.
conf.Targets["test"] = configurator.Target{
TemplatePaths: []string{},
FilePaths: []string{},
}
// create new server, add test generator, and start in background
server := server.New(&conf)
generator.DefaultGenerators["test"] = &gen
go server.Serve()
// make request to server to generate a file
res, b, err := util.MakeRequest("http://127.0.0.1:3334/generate?target=test", http.MethodGet, nil, headers)
if err != nil {
t.Fatalf("failed to make request: %v", err)
}
if res.StatusCode != http.StatusOK {
t.Fatalf("expect status code 200 from response but received %d instead", res.StatusCode)
}
// test for specific output from request
//
// 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(&conf, generator.Params{})
if err != nil {
t.Fatalf("failed to generate file: %v", err)
}
for path, contents := range fileMap {
tmp := make(map[string]string, 1)
err := json.Unmarshal(b, &tmp)
if err != nil {
t.Errorf("failed to unmarshal response: %v", err)
continue
}
if string(contents) != string(tmp[path]) {
t.Fatalf("response does not match expected output...\nexpected:%s\noutput:%s", string(contents), string(tmp[path]))
}
}
}