Moved internal/ to pkg/ to make building external plugins possible

This commit is contained in:
David Allen 2024-07-10 12:11:28 -06:00
parent 075b1a1f7f
commit 7361ec739f
No known key found for this signature in database
GPG key ID: 717C593FF60A2ACC
19 changed files with 4 additions and 3 deletions

114
pkg/auth.go Normal file
View file

@ -0,0 +1,114 @@
package configurator
import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"
"github.com/OpenCHAMI/jwtauth/v5"
"github.com/lestrrat-go/jwx/jwk"
)
func VerifyClaims(testClaims []string, r *http.Request) (bool, error) {
// extract claims from JWT
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
return false, fmt.Errorf("failed to get claims(s) from token: %v", err)
}
// verify that each one of the test claims are included
for _, testClaim := range testClaims {
_, ok := claims[testClaim]
if !ok {
return false, fmt.Errorf("failed to verify claim(s) from token: %s", testClaim)
}
}
return true, nil
}
func VerifyScope(testScopes []string, r *http.Request) (bool, error) {
// extract the scopes from JWT
var scopes []string
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
return false, fmt.Errorf("failed to get claim(s) from token: %v", err)
}
appendScopes := func(slice []string, scopeClaim any) []string {
switch scopeClaim.(type) {
case []any:
// convert all scopes to str and append
for _, s := range scopeClaim.([]any) {
switch s.(type) {
case string:
slice = append(slice, s.(string))
}
}
case []string:
slice = append(slice, scopeClaim.([]string)...)
}
return slice
}
// check for and append both "scp" and "scope" claims
v, ok := claims["scp"]
if ok {
scopes = appendScopes(scopes, v)
}
v, ok = claims["scope"]
if ok {
scopes = appendScopes(scopes, v)
}
// check for both 'scp' and 'scope' claims for scope
scopeClaim, ok := claims["scp"]
if ok {
switch scopeClaim.(type) {
case []any:
// convert all scopes to str and append
for _, s := range scopeClaim.([]any) {
switch s.(type) {
case string:
scopes = append(scopes, s.(string))
}
}
case []string:
scopes = append(scopes, scopeClaim.([]string)...)
}
}
scopeClaim, ok = claims["scope"]
if ok {
scopes = append(scopes, scopeClaim.([]string)...)
}
// verify that each of the test scopes are included
for _, testScope := range testScopes {
index := slices.Index(scopes, testScope)
if index < 0 {
return false, fmt.Errorf("invalid or missing scope")
}
}
// NOTE: should this be ok if no scopes were found?
return true, nil
}
func FetchPublicKeyFromURL(url string) (*jwtauth.JWTAuth, error) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
set, err := jwk.Fetch(ctx, url)
if err != nil {
return nil, fmt.Errorf("%v", err)
}
jwks, err := json.Marshal(set)
if err != nil {
return nil, fmt.Errorf("failed to marshal JWKS: %v", err)
}
tokenAuth, err := jwtauth.NewKeySet(jwks)
if err != nil {
return nil, fmt.Errorf("failed to initialize JWKS: %v", err)
}
return tokenAuth, nil
}

239
pkg/client.go Normal file
View file

@ -0,0 +1,239 @@
package configurator
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"time"
"github.com/OpenCHAMI/configurator/internal/util"
)
type ClientOption func(*SmdClient)
// An struct that's meant to extend functionality of the base HTTP client by
// adding commonly made requests to SMD. The implemented functions are can be
// used in generator plugins to fetch data when it is needed to substitute
// values for the Jinja templates used.
type SmdClient struct {
http.Client `json:"-"`
Host string `yaml:"host"`
Port int `yaml:"port"`
AccessToken string `yaml:"access-token"`
}
// Constructor function that allows supplying ClientOption arguments to set
// things like the host, port, access token, etc.
func NewSmdClient(opts ...ClientOption) SmdClient {
client := SmdClient{}
for _, opt := range opts {
opt(&client)
}
return client
}
func WithHost(host string) ClientOption {
return func(c *SmdClient) {
c.Host = host
}
}
func WithPort(port int) ClientOption {
return func(c *SmdClient) {
c.Port = port
}
}
func WithAccessToken(token string) ClientOption {
return func(c *SmdClient) {
c.AccessToken = token
}
}
func WithCertPool(certPool *x509.CertPool) ClientOption {
return func(c *SmdClient) {
c.Client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
InsecureSkipVerify: true,
},
DisableKeepAlives: true,
Dial: (&net.Dialer{
Timeout: 120 * time.Second,
KeepAlive: 120 * time.Second,
}).Dial,
TLSHandshakeTimeout: 120 * time.Second,
ResponseHeaderTimeout: 120 * time.Second,
}
}
}
// FIXME: Need to check for errors when reading from a file
func WithCertPoolFile(certPath string) ClientOption {
if certPath == "" {
return func(sc *SmdClient) {}
}
cacert, _ := os.ReadFile(certPath)
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(cacert)
return WithCertPool(certPool)
}
func WithVerbosity() util.Option {
return func(p util.Params) {
p["verbose"] = true
}
}
// Create a set of params with all default values.
func NewParams() util.Params {
return util.Params{
"verbose": false,
}
}
// Fetch the ethernet interfaces from SMD service using its API. An access token may be required if the SMD
// service SMD_JWKS_URL envirnoment variable is set.
func (client *SmdClient) FetchEthernetInterfaces(opts ...util.Option) ([]EthernetInterface, error) {
var (
params = util.GetParams(opts...)
verbose = util.Get[bool](params, "verbose")
eths = []EthernetInterface{}
)
// make request to SMD endpoint
b, err := client.makeRequest("/Inventory/EthernetInterfaces")
if err != nil {
return nil, fmt.Errorf("failed to read HTTP response: %v", err)
}
// unmarshal response body JSON and extract in object
err = json.Unmarshal(b, &eths)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
// print what we got if verbose is set
if verbose != nil {
if *verbose {
fmt.Printf("Ethernet Interfaces: %v\n", string(b))
}
}
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(opts ...util.Option) ([]Component, error) {
var (
params = util.GetParams(opts...)
verbose = util.Get[bool](params, "verbose")
comps = []Component{}
)
// make request to SMD endpoint
b, err := client.makeRequest("/State/Components")
if err != nil {
return nil, fmt.Errorf("failed to make HTTP request: %v", err)
}
// make sure our response is actually JSON
if !json.Valid(b) {
return nil, fmt.Errorf("expected valid JSON response: %v", string(b))
}
// unmarshal response body JSON and extract in object
var tmp map[string]any
err = json.Unmarshal(b, &tmp)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
b, err = json.Marshal(tmp["RedfishEndpoints"].([]any))
if err != nil {
return nil, fmt.Errorf("failed to marshal JSON: %v", err)
}
err = json.Unmarshal(b, &comps)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
// print what we got if verbose is set
if verbose != nil {
if *verbose {
fmt.Printf("Components: %v\n", string(b))
}
}
return comps, nil
}
func (client *SmdClient) FetchRedfishEndpoints(opts ...util.Option) ([]RedfishEndpoint, error) {
var (
params = util.GetParams(opts...)
verbose = util.Get[bool](params, "verbose")
eps = []RedfishEndpoint{}
)
b, err := client.makeRequest("/Inventory/RedfishEndpoints")
if err != nil {
return nil, fmt.Errorf("failed to make HTTP resquest: %v", err)
}
if !json.Valid(b) {
return nil, fmt.Errorf("expected valid JSON response: %v", string(b))
}
var tmp map[string]any
err = json.Unmarshal(b, &tmp)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
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)
}
if verbose != nil {
if *verbose {
fmt.Printf("Redfish endpoints: %v\n", string(b))
}
}
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:%d/hsm/v2%s", client.Host, client.Port, 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)
}

125
pkg/config.go Normal file
View file

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

59
pkg/configurator.go Normal file
View file

@ -0,0 +1,59 @@
package configurator
import "encoding/json"
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 {
}

320
pkg/generator/generator.go Normal file
View file

@ -0,0 +1,320 @@
package generator
import (
"bytes"
"fmt"
"maps"
"os"
"path/filepath"
"plugin"
configurator "github.com/OpenCHAMI/configurator/internal"
"github.com/OpenCHAMI/configurator/internal/util"
"github.com/nikolalohinski/gonja/v2"
"github.com/nikolalohinski/gonja/v2/exec"
"github.com/sirupsen/logrus"
)
type Mappings map[string]any
type FileMap map[string][]byte
type FileList [][]byte
// Generator interface used to define how files are created. Plugins can
// be created entirely independent of the main driver program.
type Generator interface {
GetName() string
GetVersion() string
GetDescription() string
Generate(config *configurator.Config, opts ...util.Option) (FileMap, error)
}
// Params defined and used by the "generate" subcommand.
type Params struct {
Args []string
PluginPaths []string
Target string
Verbose bool
}
// 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: %v", err)
}
for _, expandedPath := range expandedPaths {
info, err := os.Stat(expandedPath)
if err != nil {
fmt.Println(err)
return nil, fmt.Errorf("failed to stat file or directory: %v", err)
}
// skip any directories found
if info.IsDir() {
continue
}
b, err := os.ReadFile(expandedPath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %v", 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 path is directory: %v", err)
}
// try and open the plugin
p, err := plugin.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open plugin: %v", err)
}
// load the "Generator" symbol from plugin
symbol, err := p.Lookup("Generator")
if err != nil {
return nil, fmt.Errorf("failed to look up symbol at path '%s': %v", path, err)
}
// 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 ...util.Option) (map[string]Generator, error) {
// check if verbose option is supplied
var (
gens = make(map[string]Generator)
params = util.GetParams(opts...)
)
items, _ := os.ReadDir(dirpath)
for _, item := range items {
if item.IsDir() {
subitems, _ := os.ReadDir(item.Name())
for _, subitem := range subitems {
if !subitem.IsDir() {
gen, err := LoadPlugin(subitem.Name())
if err != nil {
fmt.Printf("failed to load generator in directory '%s': %v\n", item.Name(), err)
continue
}
if verbose, ok := params["verbose"].(bool); ok {
if verbose {
fmt.Printf("-- found plugin '%s'\n", item.Name())
}
}
gens[gen.GetName()] = gen
}
}
} else {
gen, err := LoadPlugin(dirpath + item.Name())
if err != nil {
fmt.Printf("failed to load plugin: %v\n", err)
continue
}
if verbose, ok := params["verbose"].(bool); ok {
if verbose {
fmt.Printf("-- found plugin '%s'\n", dirpath+item.Name())
}
}
gens[gen.GetName()] = gen
}
}
return gens, nil
}
// Option to specify "target" in parameter map. This is used to set which generator
// to use to generate a config file.
func WithTarget(target string) util.Option {
return func(p util.Params) {
if p != nil {
p["target"] = target
}
}
}
// Option to specify "type" in parameter map. This is not currently used.
func WithType(_type string) util.Option {
return func(p util.Params) {
if p != nil {
p["type"] = _type
}
}
}
// Option to a specific client to include in implementing plugin generator.Generate().
//
// NOTE: This may be changed to pass some kind of client interface as an argument in
// the future instead.
func WithClient(client configurator.SmdClient) util.Option {
return func(p util.Params) {
p["client"] = client
}
}
// Helper function to get client in generator.Generate() plugin implementations.
func GetClient(params util.Params) *configurator.SmdClient {
return util.Get[configurator.SmdClient](params, "client")
}
// Helper function to get the target in generator.Generate() plugin implementations.
func GetTarget(config *configurator.Config, key string) configurator.Target {
return config.Targets[key]
}
// Helper function to load all options set with With*() into parameter map.
func GetParams(opts ...util.Option) util.Params {
params := util.Params{}
for _, opt := range opts {
opt(params)
}
return params
}
// Wrapper function to slightly abstract away some of the nuances with using gonja
// into a single function call. This function is *mostly* for convenience and
// simplication. If no paths are supplied, then no templates will be applied and
// there will be no output.
//
// The "FileList" returns a slice of byte arrays in the same order as the argument
// list supplied, but with the Jinja templating applied.
func ApplyTemplates(mappings Mappings, contents ...[]byte) (FileList, error) {
var (
data = exec.NewContext(mappings)
outputs = FileList{}
)
for _, b := range contents {
// load jinja template from file
t, err := gonja.FromBytes(b)
if err != nil {
return nil, fmt.Errorf("failed to read template from file: %v", err)
}
// execute/render jinja template
b := bytes.Buffer{}
if err = t.Execute(&b, data); err != nil {
return nil, fmt.Errorf("failed to execute: %v", err)
}
outputs = append(outputs, b.Bytes())
}
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: %v", err)
}
// execute/render jinja template
b := bytes.Buffer{}
if err = t.Execute(&b, data); err != nil {
return nil, fmt.Errorf("failed to execute: %v", err)
}
outputs[path] = b.Bytes()
}
return outputs, nil
}
// 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.Generate()
// 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 Generate(config *configurator.Config, params Params) (FileMap, error) {
// load generator plugins to generate configs or to print
var (
generators = make(map[string]Generator)
client = configurator.NewSmdClient(
configurator.WithHost(config.SmdClient.Host),
configurator.WithPort(config.SmdClient.Port),
configurator.WithAccessToken(config.AccessToken),
configurator.WithCertPoolFile(config.CertPath),
)
)
// load all plugins from params
for _, path := range params.PluginPaths {
if params.Verbose {
fmt.Printf("loading plugins from '%s'\n", path)
}
gens, err := LoadPlugins(path)
if err != nil {
fmt.Printf("failed to load plugins: %v\n", err)
err = nil
continue
}
// add loaded generator plugins to set
maps.Copy(generators, gens)
}
// show available targets then exit
if len(params.Args) == 0 && params.Target == "" {
for g := range generators {
fmt.Printf("-- found generator plugin \"%s\"\n", g)
}
return nil, nil
}
if params.Target == "" {
logrus.Errorf("no target supplied (--target name)")
} else {
// run the generator plugin from target passed
gen := generators[params.Target]
if gen == nil {
return nil, fmt.Errorf("invalid generator target (%s)", params.Target)
}
return gen.Generate(
config,
WithTarget(gen.GetName()),
WithClient(client),
)
}
return nil, fmt.Errorf("an unknown error has occurred")
}

View file

@ -0,0 +1,68 @@
package main
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/internal"
"github.com/OpenCHAMI/configurator/internal/generator"
"github.com/OpenCHAMI/configurator/internal/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 *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
var (
params = generator.GetParams(opts...)
client = generator.GetClient(params)
targetKey = params["targets"].(string) // required param
target = config.Targets[targetKey]
eps []configurator.RedfishEndpoint = nil
err error = nil
// serverOpts = ""
// globalOpts = ""
consoles = ""
)
// fetch required data from SMD to create config
if client != nil {
eps, err = client.FetchRedfishEndpoints(opts...)
if err != nil {
return nil, fmt.Errorf("failed to fetch redfish endpoints with client: %v", err)
}
}
// add any additional conman or server opts
// if extraOpts, ok := params["opts"].(map[string]any); ok {
// }
// format output to write to config file
consoles = "# ========== DYNAMICALLY GENERATED BY OPENCHAMI CONFIGURATOR ==========\n"
for _, ep := range eps {
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 generator.ApplyTemplateFromFiles(generator.Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
"server_opts": "",
"global_opts": "",
}, target.Templates...)
}
var Generator Conman

View file

@ -0,0 +1,29 @@
package main
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/internal"
"github.com/OpenCHAMI/configurator/internal/generator"
"github.com/OpenCHAMI/configurator/internal/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. This plugin is not complete and still a WIP.", g.GetName())
}
func (g *CoreDhcp) Generate(config *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}
var Generator CoreDhcp

View file

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

View file

@ -0,0 +1,85 @@
package main
import (
"fmt"
"strings"
configurator "github.com/OpenCHAMI/configurator/internal"
"github.com/OpenCHAMI/configurator/internal/generator"
"github.com/OpenCHAMI/configurator/internal/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 *configurator.Config, opts ...util.Option) (generator.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 (
params = generator.GetParams(opts...)
client = generator.GetClient(params)
targetKey = params["target"].(string) // required param
target = config.Targets[targetKey]
eths []configurator.EthernetInterface = nil
err error = nil
)
// if we have a client, try making the request for the ethernet interfaces
if client != nil {
eths, err = client.FetchEthernetInterfaces(opts...)
if err != nil {
return nil, fmt.Errorf("failed to fetch ethernet interfaces with client: %v", err)
}
}
// check if we have the required params first
if eths == nil {
return nil, fmt.Errorf("invalid ethernet interfaces (variable is nil)")
}
if len(eths) <= 0 {
return nil, fmt.Errorf("no ethernet interfaces found")
}
// print message if verbose param found
if verbose, ok := params["verbose"].(bool); ok {
if verbose {
fmt.Printf("template: \n%s\nethernet interfaces found: %v\n", strings.Join(target.Templates, "\n\t"), len(eths))
}
}
// 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 generator.ApplyTemplateFromFiles(generator.Mappings{
"plugin_name": g.GetName(),
"plugin_version": g.GetVersion(),
"plugin_description": g.GetDescription(),
"dhcp-hosts": output,
}, target.Templates...)
}
var Generator DnsMasq

View file

@ -0,0 +1,34 @@
package main
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/internal"
"github.com/OpenCHAMI/configurator/internal/generator"
"github.com/OpenCHAMI/configurator/internal/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 *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
g.Message = `
This is an example generator plugin. See the file in 'internal/generator/plugins/example/example.go' on
information about constructing plugins and plugin requirements.`
return generator.FileMap{"example": []byte(g.Message)}, nil
}
var Generator Example

View file

@ -0,0 +1,29 @@
package main
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/internal"
"github.com/OpenCHAMI/configurator/internal/generator"
"github.com/OpenCHAMI/configurator/internal/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 *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}
var Generator Hostfile

View file

@ -0,0 +1,29 @@
package main
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/internal"
"github.com/OpenCHAMI/configurator/internal/generator"
"github.com/OpenCHAMI/configurator/internal/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 *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}
var Generator Powerman

View file

@ -0,0 +1,29 @@
package main
import (
"fmt"
configurator "github.com/OpenCHAMI/configurator/internal"
"github.com/OpenCHAMI/configurator/internal/generator"
"github.com/OpenCHAMI/configurator/internal/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 *configurator.Config, opts ...util.Option) (generator.FileMap, error) {
return nil, fmt.Errorf("plugin does not implement generation function")
}
var Generator Syslog

View file

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

165
pkg/server/server.go Normal file
View file

@ -0,0 +1,165 @@
//go:build server || all
// +build server all
package server
import (
"encoding/json"
"fmt"
"net/http"
"time"
configurator "github.com/OpenCHAMI/configurator/internal"
"github.com/OpenCHAMI/configurator/internal/generator"
"github.com/OpenCHAMI/jwtauth/v5"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/sirupsen/logrus"
)
var (
tokenAuth *jwtauth.JWTAuth = nil
)
type Jwks struct {
Uri string
Retries int
}
type Server struct {
*http.Server
Config *configurator.Config
Jwks Jwks `yaml:"jwks"`
GeneratorParams generator.Params
TokenAuth *jwtauth.JWTAuth
}
// Constructor to make a new server instance with an optional config.
func New(config *configurator.Config) *Server {
// create default config if none supplied
if config == nil {
c := configurator.NewConfig()
config = &c
}
// return based on config values
return &Server{
Config: config,
Server: &http.Server{
Addr: fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port),
},
Jwks: Jwks{
Uri: config.Server.Jwks.Uri,
Retries: config.Server.Jwks.Retries,
},
}
}
// Main function to start up configurator as a service.
func (s *Server) Serve() error {
// create client just for the server to use to fetch data from SMD
_ = &configurator.SmdClient{
Host: s.Config.SmdClient.Host,
Port: s.Config.SmdClient.Port,
}
// set the server address with config values
s.Server.Addr = fmt.Sprintf("%s:%d", s.Config.Server.Host, s.Config.Server.Port)
// 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 {
logrus.Errorf("failed to fetch JWKS: %w", err)
continue
}
break
}
}
// 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))
if s.Config.Server.Jwks.Uri != "" {
router.Group(func(r chi.Router) {
r.Use(
jwtauth.Verifier(tokenAuth),
jwtauth.Authenticator(tokenAuth),
)
// protected routes if using auth
r.HandleFunc("/generate", s.Generate)
r.HandleFunc("/templates", s.ManageTemplates)
})
} else {
// public routes without auth
router.HandleFunc("/generate", s.Generate)
router.HandleFunc("/templates", s.ManageTemplates)
}
// always available public routes go here (none at the moment)
s.Handler = router
return s.ListenAndServe()
}
func (s *Server) Close() {
}
// This is the corresponding service function to generate templated files, that
// works similarly to the CLI variant. This function takes similiar arguments as
// query parameters that are included in the HTTP request URL.
func (s *Server) Generate(w http.ResponseWriter, r *http.Request) {
// get all of the expect query URL params and validate
s.GeneratorParams.Target = r.URL.Query().Get("target")
if s.GeneratorParams.Target == "" {
writeError(w, "no targets supplied")
return
}
// generate a new config file from supplied params
outputs, err := generator.Generate(s.Config, s.GeneratorParams)
if err != nil {
writeError(w, "failed to generate config: %v", err)
return
}
// marshal output to JSON then send response to client
tmp := generator.ConvertContentsToString(outputs)
b, err := json.Marshal(tmp)
if err != nil {
writeError(w, "failed to marshal output: %v", err)
return
}
_, err = w.Write(b)
if err != nil {
writeError(w, "failed to write response: %v", err)
return
}
}
// Incomplete WIP function for managing templates remotely. There is currently no
// internal API to do this yet.
//
// TODO: need to implement template managing API first in "internal/generator/templates" or something
func (s *Server) ManageTemplates(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("this is not implemented yet"))
if err != nil {
writeError(w, "failed to write response: %v", err)
return
}
}
// Wrapper function to simplify writting error message responses. This function
// is only intended to be used with the service and nothing else.
func writeError(w http.ResponseWriter, format string, a ...any) {
errmsg := fmt.Sprintf(format, a...)
fmt.Printf(errmsg)
w.Write([]byte(errmsg))
}

57
pkg/util/params.go Normal file
View file

@ -0,0 +1,57 @@
package util
import (
"slices"
"golang.org/x/exp/maps"
)
// Params are accessible in generator.Generate().
type Params map[string]any
type Option func(Params)
// Extract all parameters from the options passed as map[string]any.
func GetParams(opts ...Option) Params {
params := Params{}
for _, opt := range opts {
opt(params)
}
return params
}
// Test if an option is present in params
func (p *Params) OptionExists(params Params, opt string) bool {
var k []string = maps.Keys(params)
return slices.Contains(k, opt)
}
// Assert that the options exists within the params map
func AssertOptionsExist(params Params, opts ...string) []string {
foundKeys := []string{}
for k := range params {
index := slices.IndexFunc(opts, func(s string) bool {
return s == k
})
if index >= 0 {
foundKeys = append(foundKeys, k)
}
}
return foundKeys
}
func WithDefault[T any](v T) Option {
return func(p Params) {
p["default"] = v
}
}
// Syntactic sugar generic function to get parameter from util.Params.
func Get[T any](params Params, key string, opts ...Option) *T {
if v, ok := params[key].(T); ok {
return &v
}
if defaultValue, ok := params["default"].(T); ok {
return &defaultValue
}
return nil
}

92
pkg/util/util.go Normal file
View file

@ -0,0 +1,92 @@
package util
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
)
// Wrapper function to simplify checking if a path exists.
func PathExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
// Wrapper function to simplify checking if a path is a directory.
func IsDirectory(path string) (bool, error) {
// This returns an *os.FileInfo type
fileInfo, err := os.Stat(path)
if err != nil {
return false, fmt.Errorf("failed to stat path: %v", err)
}
// IsDir is short for fileInfo.Mode().IsDir()
return fileInfo.IsDir(), nil
}
// Wrapper function to confine making a HTTP request into a single function
// instead of multiple.
func MakeRequest(url string, httpMethod string, body []byte, headers map[string]string) (*http.Response, []byte, error) {
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
req, err := http.NewRequest(httpMethod, url, bytes.NewBuffer(body))
if err != nil {
return nil, nil, fmt.Errorf("could not create new HTTP request: %v", err)
}
req.Header.Add("User-Agent", "configurator")
for k, v := range headers {
req.Header.Add(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("could not make request: %v", err)
}
b, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return nil, nil, fmt.Errorf("could not read response body: %v", err)
}
return res, b, err
}
// Returns the git commit string by executing command.
// NOTE: This currently requires git to be installed.
// TODO: Change how this is done to not require executing a command.
func GitCommit() string {
c := exec.Command("git", "rev-parse", "HEAD")
stdout, err := c.Output()
if err != nil {
return ""
}
return strings.TrimRight(string(stdout), "\n")
}
// General function to remove element by a given index.
// NOTE: would it be better to use slices.DeleteFunc instead?
func RemoveIndex[T comparable](s []T, index int) []T {
ret := make([]T, 0)
ret = append(ret, s[:index]...)
return append(ret, s[index+1:]...)
}
// General function to copy elements from slice if condition is true.
func CopyIf[T comparable](s []T, condition func(t T) bool) []T {
var f = make([]T, 0)
for _, e := range s {
if condition(e) {
f = append(f, e)
}
}
return f
}