Initial cut of wwapi.
We need an API for Warewulf in order to automate around it. The initial version of the API will look a lot like wwctl. wwctl will call the API so that we do not need to maintain separate code paths. In this preview wwctl calls the API via direct function call, but we can change that to a gprc or REST call to a Warewulf server in the future.
This commit contains:
wwapid WareWulf API Daemon. A grpc server with mTLS auth.
wwapic WareWulf API Client. A grpc client with mTLS auth. It's just a sample that reads the version from the server.
wwapird WareWulf API Rest Daemon. A http REST reverse proxy to wwapid.
There are also sample insecure and secure curls to test with.
Implemented Functionality:
wwctl node add
wwctl node delete
wwctl node list
wwctl node set
wwctl node status
wwctl container build
wwctl container delete
wwctl container import
wwctl container list
wwctl container show
Some notes on the files:
Logic that was in wwctl has moved to warewulf/internal/pkg/api. wwctl just calls the API. wwctl functionality is unchanged.
Everything under the /google directory is copied google proto code to stand up wwapird. It's a bit strange to copy in the code, but that is currently how it's done.
Everything in warewulf/internal/pkg/api/routes/wwapiv1 is generated code.
There are some loose ends here, such as no service installers. I just ran off the command line for development.
The Makefile could use improvement. I'm building by make clean setup proto all build wwapid wwapic wwapird ; echo $?
I copied the configs from warewulf/etc to /usr/local/etc/warewulf manually.
This commit is contained in:
41
internal/pkg/api/apiconfig/client.go
Normal file
41
internal/pkg/api/apiconfig/client.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package apiconfig
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v2"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ClientApiConfig contains configuration parameters for an API server.
|
||||
type ClientApiConfig struct {
|
||||
// Server is the hostname or IP address of the server to connect to.
|
||||
Server string `yaml:"prefix"`
|
||||
// Port is the where the API server listens.
|
||||
Port uint32 `yaml:"port"`
|
||||
}
|
||||
|
||||
// ClientConfig is the full client configuration.
|
||||
type ClientConfig struct {
|
||||
ApiConfig ClientApiConfig `yaml:"api"`
|
||||
TlsConfig TlsConfig `yaml:"tls"`
|
||||
}
|
||||
|
||||
// NewClient loads the client config from the given configFilePath.
|
||||
func NewClient(configFilePath string) (config ClientConfig, err error) {
|
||||
|
||||
log.Printf("Loading api client configuration from: %v\n", configFilePath)
|
||||
|
||||
var fileBytes []byte
|
||||
fileBytes, err = ioutil.ReadFile(configFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(fileBytes, &config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("api client config: %#v\n", config)
|
||||
return
|
||||
}
|
||||
37
internal/pkg/api/apiconfig/client_server.go
Normal file
37
internal/pkg/api/apiconfig/client_server.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package apiconfig
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v2"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ClientServerConfig is the full client server configuration.
|
||||
// wwapird is a client of wwapid.
|
||||
// wwapird serves REST. (WareWulf API Rest Daemon)
|
||||
type ClientServerConfig struct {
|
||||
ClientApiConfig ClientApiConfig `yaml:"clientapi"`
|
||||
ServerApiConfig ServerApiConfig `yaml:"serverapi"`
|
||||
ClientTlsConfig TlsConfig `yaml:"clienttls"`
|
||||
ServerTlsConfig TlsConfig `yaml:"servertls"`
|
||||
}
|
||||
|
||||
// NewClientServer loads the client config from the given configFilePath.
|
||||
func NewClientServer(configFilePath string) (config ClientServerConfig, err error) {
|
||||
|
||||
log.Printf("Loading api client server configuration from: %v\n", configFilePath)
|
||||
|
||||
var fileBytes []byte
|
||||
fileBytes, err = ioutil.ReadFile(configFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(fileBytes, &config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("api client server config: %#v\n", config)
|
||||
return
|
||||
}
|
||||
379
internal/pkg/api/apiconfig/container/container.go
Normal file
379
internal/pkg/api/apiconfig/container/container.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/containers/image/v5/types"
|
||||
"github.com/hpcng/warewulf/internal/pkg/api/routes/wwapiv1"
|
||||
"github.com/hpcng/warewulf/internal/pkg/container"
|
||||
"github.com/hpcng/warewulf/internal/pkg/node"
|
||||
"github.com/hpcng/warewulf/internal/pkg/util"
|
||||
"github.com/hpcng/warewulf/internal/pkg/warewulfd"
|
||||
"github.com/hpcng/warewulf/internal/pkg/wwlog"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// TODO: SynchUser is not NYI in the API: See https://github.com/hpcng/warewulf/blob/main/internal/app/wwctl/container/syncuser/main.go
|
||||
|
||||
func ContainerBuild(cbp *wwapiv1.ContainerBuildParameter) (err error) {
|
||||
|
||||
if cbp == nil {
|
||||
return fmt.Errorf("ContainerBuildParameter is nil")
|
||||
}
|
||||
|
||||
var containers []string
|
||||
|
||||
if cbp.All {
|
||||
containers, err = container.ListSources()
|
||||
} else {
|
||||
containers = cbp.ContainerNames
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, c := range containers {
|
||||
if !container.ValidSource(c) {
|
||||
err = fmt.Errorf("VNFS name does not exist: %s", c)
|
||||
wwlog.Printf(wwlog.ERROR, "%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = container.Build(c, cbp.Force)
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "Could not build container %s: %s\n", c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if cbp.Default {
|
||||
if len(containers) != 1 {
|
||||
wwlog.Printf(wwlog.ERROR, "Can only set default for one container\n")
|
||||
} else {
|
||||
var nodeDB node.NodeYaml
|
||||
nodeDB, err = node.New()
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "Could not open node configuration: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
//TODO: Don't loop through profiles, instead have a nodeDB function that goes directly to the map
|
||||
profiles, _ := nodeDB.FindAllProfiles()
|
||||
for _, profile := range profiles {
|
||||
wwlog.Printf(wwlog.DEBUG, "Looking for profile default: %s\n", profile.Id.Get())
|
||||
if profile.Id.Get() == "default" {
|
||||
wwlog.Printf(wwlog.DEBUG, "Found profile default, setting container name to: %s\n", containers[0])
|
||||
profile.ContainerName.Set(containers[0])
|
||||
err := nodeDB.ProfileUpdate(profile)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to update node profile")
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Need a wrapper and flock around this. Sometimes we restart warewulfd and sometimes we don't.
|
||||
err = nodeDB.Persist()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to persist nodedb")
|
||||
}
|
||||
fmt.Printf("Set default profile to container: %s\n", containers[0])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ContainerDelete(cdp *wwapiv1.ContainerDeleteParameter) (err error) {
|
||||
|
||||
if cdp == nil {
|
||||
return fmt.Errorf("ContainerDeleteParameter is nil")
|
||||
}
|
||||
|
||||
nodeDB, err := node.New()
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "Could not open nodeDB: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
nodes, err := nodeDB.FindAllNodes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ARG_LOOP:
|
||||
for i := 0; i < len(cdp.ContainerNames); i++ {
|
||||
//_, arg := range args {
|
||||
containerName := cdp.ContainerNames[i]
|
||||
for _, n := range nodes {
|
||||
if n.ContainerName.Get() == containerName {
|
||||
wwlog.Printf(wwlog.ERROR, "Container is configured for nodes, skipping: %s\n", containerName)
|
||||
continue ARG_LOOP
|
||||
}
|
||||
}
|
||||
|
||||
if !container.ValidSource(containerName) {
|
||||
wwlog.Printf(wwlog.ERROR, "Container name is not a valid source: %s\n", containerName)
|
||||
continue
|
||||
}
|
||||
err := container.DeleteSource(containerName)
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "Could not remove source: %s\n", containerName)
|
||||
} else {
|
||||
fmt.Printf("Container has been deleted: %s\n", containerName)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func ContainerImport(cip *wwapiv1.ContainerImportParameter) (containerName string, err error) {
|
||||
|
||||
if cip == nil {
|
||||
err = fmt.Errorf("NodeAddParameter is nil")
|
||||
return
|
||||
}
|
||||
|
||||
if cip.Name == "" {
|
||||
name := path.Base(cip.Source)
|
||||
fmt.Printf("Setting VNFS name: %s\n", name)
|
||||
cip.Name = name
|
||||
}
|
||||
if !container.ValidName(cip.Name) {
|
||||
err = fmt.Errorf("VNFS name contains illegal characters: %s", cip.Name)
|
||||
wwlog.Printf(wwlog.ERROR, "%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
containerName = cip.Name
|
||||
fullPath := container.SourceDir(cip.Name)
|
||||
|
||||
if util.IsDir(fullPath) {
|
||||
if cip.Force {
|
||||
fmt.Printf("Overwriting existing VNFS\n")
|
||||
err = os.RemoveAll(fullPath)
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "%s\n", err)
|
||||
return
|
||||
}
|
||||
} else if cip.Update {
|
||||
fmt.Printf("Updating existing VNFS\n")
|
||||
} else {
|
||||
err = fmt.Errorf("VNFS Name exists, specify --force, --update, or choose a different name: %s", cip.Name)
|
||||
wwlog.Printf(wwlog.ERROR, "%s\n", err)
|
||||
return
|
||||
}
|
||||
} else if strings.HasPrefix(cip.Source, "docker://") || strings.HasPrefix(cip.Source, "docker-daemon://") {
|
||||
var sCtx *types.SystemContext
|
||||
sCtx, err = getSystemContext()
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "%s\n", err)
|
||||
// return was missing here. Was that deliberate?
|
||||
}
|
||||
|
||||
err = container.ImportDocker(cip.Source, cip.Name, sCtx)
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "Could not import image: %s\n", err)
|
||||
_ = container.DeleteSource(cip.Name)
|
||||
return
|
||||
}
|
||||
} else if util.IsDir(cip.Source) {
|
||||
err = container.ImportDirectory(cip.Source, cip.Name)
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "Could not import image: %s\n", err)
|
||||
_ = container.DeleteSource(cip.Name)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = fmt.Errorf("Invalid dir or uri: %s", cip.Source)
|
||||
wwlog.Printf(wwlog.ERROR, "%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Updating the container's /etc/resolv.conf\n")
|
||||
err = util.CopyFile("/etc/resolv.conf", path.Join(container.RootFsDir(cip.Name), "/etc/resolv.conf"))
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.WARN, "Could not copy /etc/resolv.conf into container: %s\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Building container: %s\n", cip.Name)
|
||||
err = container.Build(cip.Name, true)
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "Could not build container %s: %s\n", cip.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cip.Default {
|
||||
var nodeDB node.NodeYaml
|
||||
nodeDB, err = node.New()
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "Could not open node configuration: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
//TODO: Don't loop through profiles, instead have a nodeDB function that goes directly to the map
|
||||
profiles, _ := nodeDB.FindAllProfiles()
|
||||
for _, profile := range profiles {
|
||||
wwlog.Printf(wwlog.DEBUG, "Looking for profile default: %s\n", profile.Id.Get())
|
||||
if profile.Id.Get() == "default" {
|
||||
wwlog.Printf(wwlog.DEBUG, "Found profile default, setting container name to: %s\n", cip.Name)
|
||||
profile.ContainerName.Set(cip.Name)
|
||||
err = nodeDB.ProfileUpdate(profile)
|
||||
if err != nil {
|
||||
err = errors.Wrap(err, "failed to update profile")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: We need this in a function with a flock around it.
|
||||
// Also need to understand if the daemon restart is only to
|
||||
// reload the config or if there is something more.
|
||||
err = nodeDB.Persist()
|
||||
if err != nil {
|
||||
err = errors.Wrap(err, "failed to persist nodedb")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Set default profile to container: %s\n", cip.Name)
|
||||
err = warewulfd.DaemonReload()
|
||||
if err != nil {
|
||||
err = errors.Wrap(err, "failed to reload warewulf daemon")
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ContainerList() (containerInfo []*wwapiv1.ContainerInfo, err error) {
|
||||
var sources []string
|
||||
|
||||
sources, err = container.ListSources()
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
nodeDB, err := node.New()
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
nodes, err := nodeDB.FindAllNodes()
|
||||
if err != nil {
|
||||
wwlog.Printf(wwlog.ERROR, "%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
nodemap := make(map[string]int)
|
||||
for _, n := range nodes {
|
||||
nodemap[n.ContainerName.Get()]++
|
||||
}
|
||||
|
||||
for _, source := range sources {
|
||||
if nodemap[source] == 0 {
|
||||
nodemap[source] = 0
|
||||
}
|
||||
|
||||
wwlog.Printf(wwlog.DEBUG, "Finding kernel version for: %s\n", source)
|
||||
kernelVersion := container.KernelVersion(source)
|
||||
|
||||
containerInfo = append(containerInfo, &wwapiv1.ContainerInfo{
|
||||
Name: source,
|
||||
NodeCount: uint32(nodemap[source]),
|
||||
KernelVersion: kernelVersion,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ContainerShow(csp *wwapiv1.ContainerShowParameter) (response *wwapiv1.ContainerShowResponse, err error) {
|
||||
|
||||
containerName := csp.ContainerName
|
||||
|
||||
if !container.ValidName(containerName) {
|
||||
err = fmt.Errorf("%s is not a valid container", containerName)
|
||||
return
|
||||
}
|
||||
|
||||
rootFsDir := container.RootFsDir(containerName)
|
||||
|
||||
nodeDB, err := node.New()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
nodes, err := nodeDB.FindAllNodes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var nodeList []string
|
||||
for _, n := range nodes {
|
||||
if n.ContainerName.Get() == containerName {
|
||||
|
||||
nodeList = append(nodeList, n.Id.Get())
|
||||
}
|
||||
}
|
||||
|
||||
response = &wwapiv1.ContainerShowResponse{
|
||||
Name: containerName,
|
||||
Rootfs: rootFsDir,
|
||||
Nodes: nodeList,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
func setOCICredentials(sCtx *types.SystemContext) error {
|
||||
username, userSet := os.LookupEnv("WAREWULF_OCI_USERNAME")
|
||||
password, passSet := os.LookupEnv("WAREWULF_OCI_PASSWORD")
|
||||
if userSet || passSet {
|
||||
if userSet && passSet {
|
||||
sCtx.DockerAuthConfig = &types.DockerAuthConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("oci username and password env vars must be specified together")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setNoHTTPSOpts(sCtx *types.SystemContext) error {
|
||||
val, ok := os.LookupEnv("WAREWULF_OCI_NOHTTPS")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
noHTTPS, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("while parsing insecure http option: %v", err)
|
||||
}
|
||||
|
||||
// only set this if we want to disable, otherwise leave as undefined
|
||||
if noHTTPS {
|
||||
sCtx.DockerInsecureSkipTLSVerify = types.NewOptionalBool(true)
|
||||
}
|
||||
sCtx.OCIInsecureSkipTLSVerify = noHTTPS
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSystemContext() (sCtx *types.SystemContext, err error) {
|
||||
sCtx = &types.SystemContext{}
|
||||
|
||||
if err := setOCICredentials(sCtx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := setNoHTTPSOpts(sCtx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sCtx, nil
|
||||
}
|
||||
43
internal/pkg/api/apiconfig/server.go
Normal file
43
internal/pkg/api/apiconfig/server.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package apiconfig
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v2"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ServerApiConfig contains configuration parameters for an API server.
|
||||
type ServerApiConfig struct {
|
||||
// Version contains the full version of the API server, eg: 1.0.0.
|
||||
Version string `yaml:"version"`
|
||||
// Prefix contains the version url prefix for the API server, eg: v1.
|
||||
Prefix string `yaml:"prefix"`
|
||||
// Port is the where the API server listens.
|
||||
Port uint32 `yaml:"port"`
|
||||
}
|
||||
|
||||
// ServerConfig is the full server configuration.
|
||||
type ServerConfig struct {
|
||||
ApiConfig ServerApiConfig `yaml:"api"`
|
||||
TlsConfig TlsConfig `yaml:"tls"`
|
||||
}
|
||||
|
||||
// NewServer loads the server config from the given configFilePath.
|
||||
func NewServer(configFilePath string) (config ServerConfig, err error) {
|
||||
|
||||
log.Printf("Loading api server configuration from: %v\n", configFilePath)
|
||||
|
||||
var fileBytes []byte
|
||||
fileBytes, err = ioutil.ReadFile(configFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(fileBytes, &config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("api server config: %#v\n", config)
|
||||
return
|
||||
}
|
||||
20
internal/pkg/api/apiconfig/tls.go
Normal file
20
internal/pkg/api/apiconfig/tls.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package apiconfig
|
||||
|
||||
// TlsConfig contains TLS configuration parameters for a client or server.
|
||||
type TlsConfig struct {
|
||||
// Enabled is true when secure.
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// Cert is the path to the client or server certificate file.
|
||||
Cert string `yaml:"cert,omitempty"`
|
||||
// Key is the path to the client or server key file.
|
||||
Key string `yaml:"key,omitempty"`
|
||||
// CaCert is the path the CA certificate file.
|
||||
CaCert string `yaml:"cacert,omitempty"`
|
||||
// ConcatCert is for wwapird. http.ListenAndServeTLS wants the following
|
||||
// cert file, so in our case this file contains `cat ${Cert} ${CaCert}`
|
||||
//
|
||||
// If the certificate is signed by a certificate authority, the certFile
|
||||
// should be the concatenation of the server's certificate, any
|
||||
// intermediates, and the CA's certificate
|
||||
ConcatCert string `yaml:"concatcert,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user