refactor: initial commit for major rewrite

This commit is contained in:
David Allen 2025-08-03 20:25:18 -06:00
parent 3253cb8bbb
commit bfd83f35a3
Signed by: towk
GPG key ID: 0430CDBE22619155
45 changed files with 439 additions and 1733 deletions

View file

@ -5,6 +5,10 @@ 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.2.0]
- Major rewrite of entire code base
[0.1.0]
- Initial prerelease of configurator

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)
}

7
cmd/download.go Normal file
View file

@ -0,0 +1,7 @@
package cmd
import "github.com/spf13/cobra"
var downloadCmd = cobra.Command{
Use: "download",
}

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)
}

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)
}

0
cmd/list.go Normal file
View file

0
cmd/profiles.go Normal file
View file

0
cmd/render.go Normal file
View file

View file

@ -4,35 +4,20 @@ import (
"fmt"
"os"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
"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
)
var rootCmd = &cobra.Command{
Use: "configurator",
Short: "Dynamically generate files defined by generators",
var rootCmd = cobra.Command{
Use: "configurator",
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
cmd.Help()
os.Exit(0)
}
},
}
func Execute() {
// run initialization code first
initEnv()
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@ -40,39 +25,13 @@ 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)")
// initialize the config a single time
}
func InitConfig() {
// empty from not being set
if configPath != "" {
exists, err := util.PathExists(configPath)
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")
os.Exit(1)
}
} else {
// set to the default value and create a new one
configPath = "./config.yaml"
conf = config.New()
}
func initConfigFromFile(path string) {
}
func initEnv() {
//
// 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
}
}

View file

@ -1,67 +0,0 @@
//go:build server || all
// +build server all
package cmd
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"github.com/OpenCHAMI/configurator/pkg/server"
"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",
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()
)
if errors.Is(err, http.ErrServerClosed) {
if verbose {
log.Info().Msg("server closed")
}
} else if err != nil {
log.Error().Err(err).Msg("failed to start server")
os.Exit(1)
}
},
}
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")
rootCmd.AddCommand(serveCmd)
}

14
cmd/upload.go Normal file
View file

@ -0,0 +1,14 @@
package cmd
import "github.com/spf13/cobra"
var uploadCmd = &cobra.Command{
Use: "upload",
Run: func(cmd *cobra.Command, args []string) {
},
}
func init() {
rootCmd.AddCommand(uploadCmd)
}

View file

@ -1,8 +1,8 @@
package main
import (
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/generator"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/generator"
)
type TestGenerator struct{}

View file

@ -5,7 +5,7 @@
# Description: {{ plugin_description }}
#
# Source code: https://github.com/OpenCHAMI/configurator
# Creating plugins: https://github.com/OpenCHAMI/configurator/blob/main/README.md#creating-generator-plugins
# Creating plugins: https://git.towk2.me/towk/configurator/blob/main/README.md#creating-generator-plugins
#
SERVER keepalive=ON
SERVER logdir="/var/log/conman"

View file

@ -5,7 +5,7 @@
# Description: {{ plugin_description }}
#
# Source code: https://github.com/OpenCHAMI/configurator
# Creating plugins: https://github.com/OpenCHAMI/configurator/blob/main/README.md#creating-generator-plugins
# Creating plugins: https://git.towk2.me/towk/configurator/blob/main/README.md#creating-generator-plugins
#
allow booting;
allow bootp;

View file

@ -5,6 +5,6 @@
# Description: {{ plugin_description }}
#
# Source code: https://github.com/OpenCHAMI/configurator
# Creating plugins: https://github.com/OpenCHAMI/configurator/blob/main/README.md#creating-generator-plugins
# Creating plugins: https://git.towk2.me/towk/configurator/blob/main/README.md#creating-generator-plugins
#
{{ dhcp_hosts }}

View file

@ -5,7 +5,7 @@
# Description: {{ plugin_description }}
#
# Source code: https://github.com/OpenCHAMI/configurator
# Creating plugins: https://github.com/OpenCHAMI/configurator/blob/main/README.md#creating-generator-plugins
# Creating plugins: https://git.towk2.me/towk/configurator/blob/main/README.md#creating-generator-plugins
#
include "/etc/powerman/ipmipower.dev"
include "/etc/powerman/ipmi.dev"

View file

@ -5,7 +5,7 @@
# Description: {{ plugin_description }}
#
# Source code: https://github.com/OpenCHAMI/configurator
# Creating plugins: https://github.com/OpenCHAMI/configurator/blob/main/README.md#creating-generator-plugins
# Creating plugins: https://git.towk2.me/towk/configurator/blob/main/README.md#creating-generator-plugins
#
# TODO: test variables

4
go.mod
View file

@ -1,4 +1,4 @@
module github.com/OpenCHAMI/configurator
module git.towk2.me/towk/configurator
go 1.21.5
@ -11,7 +11,6 @@ require (
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/spf13/cobra v1.8.0
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8
gopkg.in/yaml.v2 v2.4.0
@ -35,6 +34,7 @@ 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
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/sys v0.22.0 // indirect

View file

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

View file

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

View file

@ -1,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

@ -3,10 +3,10 @@ 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"
configurator "git.towk2.me/towk/configurator/pkg"
"git.towk2.me/towk/configurator/pkg/client"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/util"
)
type Conman struct{}

View file

@ -3,8 +3,8 @@ package generator
import (
"fmt"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/util"
)
type CoreDhcp struct{}

View file

@ -3,10 +3,10 @@ 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"
configurator "git.towk2.me/towk/configurator/pkg"
"git.towk2.me/towk/configurator/pkg/client"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/util"
)
type DHCPd struct{}

View file

@ -3,10 +3,10 @@ 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"
configurator "git.towk2.me/towk/configurator/pkg"
"git.towk2.me/towk/configurator/pkg/client"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/util"
)
type DNSMasq struct{}

View file

@ -3,8 +3,8 @@ package generator
import (
"fmt"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/util"
)
type Example struct {

View file

@ -7,10 +7,10 @@ import (
"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"
configurator "git.towk2.me/towk/configurator/pkg"
"git.towk2.me/towk/configurator/pkg/client"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/util"
"github.com/rs/zerolog/log"
)

View file

@ -3,8 +3,8 @@ package generator
import (
"fmt"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/util"
)
type Hostfile struct{}

View file

@ -1,9 +1,9 @@
package generator
import (
configurator "github.com/OpenCHAMI/configurator/pkg"
"github.com/OpenCHAMI/configurator/pkg/client"
"github.com/OpenCHAMI/configurator/pkg/config"
configurator "git.towk2.me/towk/configurator/pkg"
"git.towk2.me/towk/configurator/pkg/client"
"git.towk2.me/towk/configurator/pkg/config"
)
type (

View file

@ -3,8 +3,8 @@ package generator
import (
"fmt"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/util"
)
type Powerman struct{}

View file

@ -3,8 +3,8 @@ package generator
import (
"fmt"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/util"
)
type Syslog struct{}

View file

@ -5,7 +5,7 @@ import (
"fmt"
"os"
"github.com/OpenCHAMI/configurator/pkg/util"
"git.towk2.me/towk/configurator/pkg/util"
"github.com/nikolalohinski/gonja/v2"
"github.com/nikolalohinski/gonja/v2/exec"
"github.com/rs/zerolog/log"

View file

@ -5,9 +5,9 @@ import (
"maps"
"strings"
"github.com/OpenCHAMI/configurator/pkg/client"
"github.com/OpenCHAMI/configurator/pkg/config"
"github.com/OpenCHAMI/configurator/pkg/util"
"git.towk2.me/towk/configurator/pkg/client"
"git.towk2.me/towk/configurator/pkg/config"
"git.towk2.me/towk/configurator/pkg/util"
"github.com/rs/zerolog/log"
)

14
pkg/plugin.go Normal file
View file

@ -0,0 +1,14 @@
package configurator
type Plugin interface {
// plugin data
Name() string
Version() string
Description() string
Metadata() map[string]string
// run the plugin
Init() error
Run() error
Cleanup() error
}

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
}

184
pkg/service/profile.go Normal file
View file

@ -0,0 +1,184 @@
package service
import (
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"github.com/go-chi/chi/v5"
)
type Profile struct {
ID string `json:"id"` // profile ID
Description string `json:"description"` // profile description
Tags []string `json:"tags"` // tags used for ...
Paths []string `json:"paths"` // paths to download
Plugins []string `json:"plugins"` // plugins to run
Data map[string]any `json:"data"` // include render data
}
func (s *Service) GetProfiles() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
path = s.RootPath + PROFILES_RELPATH
profiles []*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 *Profile
profile, err = LoadProfile(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) CreateProfiles() http.HandlerFunc {
// return func(w http.ResponseWriter, r *http.Request) {
// var (
// path = chi.URLParam(r, "path")
// err error
// )
// }
// }
func (s *Service) GetProfile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
id = chi.URLParam(r, "id")
path = s.BuildProfilePath(id)
profile *Profile
contents []byte
err error
)
profile, err = LoadProfile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
contents, err = json.Marshal(profile)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, 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) {
type input struct {
path string `json:"path"`
profile *Profile `json:"profile"`
}
var (
body []byte
in input
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, &in)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// create a new profile on disk
os.WriteFile()
w.WriteHeader(http.StatusOK)
}
}
func (s *Service) CreateProfileVar() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func (s *Service) DeleteProfileVar() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func (s *Service) GetProfileData() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func (s *Service) GetProfileVar() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func (s *Service) CreateProfilePath() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func (s *Service) DeleteProfilePath() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func (s *Service) GetProfilePath() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func (s *Service) GetPlugins() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func (s *Service) CreatePlugins() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func (s *Service) DeletePlugins() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}

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

@ -0,0 +1,38 @@
package service
import (
"encoding/json"
"fmt"
"net/http"
)
func (s *Service) Download() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Service) Upload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Service) List() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Service) 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
}
}

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

@ -0,0 +1,122 @@
package service
import (
"encoding/json"
"fmt"
"os"
"time"
configurator "git.towk2.me/towk/configurator/pkg"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/chi/v5"
)
const (
PLUGINS_RELPATH = "/plugins"
TEMPLATES_RELPATH = "/templates"
PROFILES_RELPATH = "/profiles"
)
type Service struct {
RootPath string `yaml:"root,omitempty"`
Environment map[string]string
// max counts
PluginsMaxCount int
ProfilesMaxCount int
}
// New creates the directories at specified path
func New() *Service {
return &Service{
RootPath: ".",
Environment: map[string]string{
"CONFIGURATOR_HOST_URI": "",
"ACCESS_TOKEN": "",
},
PluginsMaxCount: 64,
ProfilesMaxCount: 256,
}
}
// Serve() starts the configurator 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(60 * time.Second))
if s.requireAuth() {
} else {
// general
router.Get("/download", s.Download())
router.Post("/upload", s.Upload())
router.Get("/list", s.List())
// profiles
router.Get("/profiles", s.GetProfiles())
// router.Post("/profiles", s.CreateProfiles())
router.Get("/profile/{id}", s.GetProfile())
router.Post("/profile/{id}", s.CreateProfile())
router.Post("/profile/{id}/data/{varname}", s.CreateProfileVar())
router.Delete("/profile/{id}/data/{varname}", s.DeleteProfileVar())
router.Get("/profile/{id}/data", s.GetProfileData())
router.Get("/profile/{id}/data/{varname}", s.GetProfileVar())
router.Post("/profile/{id}/paths/{path}", s.CreateProfilePath())
router.Delete("/profile/{id}/paths/{path}", s.DeleteProfilePath())
router.Get("/profile/{id}/paths/{path}", s.GetProfilePath())
// plugins
router.Get("/plugins", s.GetPlugins())
router.Post("/plugins", s.CreatePlugins())
router.Delete("/plugins/{id}", s.DeletePlugins())
}
// always available public routes go here
router.HandleFunc("/status", s.GetStatus)
return nil
}
func (s *Service) requireAuth() bool {
return false
}
func (s *Service) FetchJwks(uri string) {
}
func LoadProfile(path string) (*Profile, error) {
return LoadFromJSONFile[Profile](path)
}
func LoadPlugin(path string) (*configurator.Plugin, error) {
return LoadFromJSONFile[configurator.Plugin](path)
}
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 plugin file: %v", err)
}
err = json.Unmarshal(contents, &res)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal plugin: %v", err)
}
return res, err
}
func (s *Service) BuildProfilePath(id string) string {
return s.RootPath + PLUGINS_RELPATH + "/" + id
}

View file

@ -9,7 +9,7 @@ license=('MIT')
groups=("openchami")
provides=('configurator')
conflicts=('configurator')
https://github.com/OpenCHAMI/configurator/releases/download/v0.1.0-alpha/configurator
https://git.towk2.me/towk/configurator/releases/download/v0.1.0-alpha/configurator
source_x86_64=(
"${url}/releases/download/v0.1.0-alpha/${pkgname}.tar.gz"
)

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]))
}
}
}