Skip to content

Commit

Permalink
Add Golang-based deploy-cli which handles bmo and ironic deployments
Browse files Browse the repository at this point in the history
Signed-off-by: Max Rantil <[email protected]>

Signed-off-by: Huy Mai <[email protected]>
  • Loading branch information
Max Rantil authored and mquhuy committed Jul 29, 2024
1 parent 12df962 commit 8d7c6cf
Show file tree
Hide file tree
Showing 11 changed files with 1,493 additions and 2 deletions.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ $(CONTROLLER_GEN): hack/tools/go.mod
$(KUSTOMIZE): hack/tools/go.mod
cd hack/tools; go build -o $(abspath $@) sigs.k8s.io/kustomize/kustomize/v4

.PHONY: deploy-cli
deploy-cli: $(KUSTOMIZE) ## Build deploy-cli binary
cd tools/deploy-cli; go build -o ../bin/deploy-cli .

.PHONY: build-e2e
build-e2e:
cd test; go build ./...
Expand Down
9 changes: 8 additions & 1 deletion config/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
# Kustomizations for Baremetal Operator

This folder contains kustomizations for the Baremetal Operator. They have
traditionally been used through the [deploy.sh](../tools/deploy.sh) script,
been used through the [deploy-cli](../tools/deploy-cli) library,
which takes care of generating the necessary config for basic-auth and TLS.
To ensure this executable is available, you must first build `deploy-cli` by
running the following command from the project root:

```shell
make deploy-cli
```

However, a more GitOps friendly way would be to create your own static overlay.
Check the `overlays/e2e` for an example that is used in the e2e tests.
In the CI system we generate the necessary credentials before starting the test
Expand Down
8 changes: 7 additions & 1 deletion ironic-deployment/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# Kustomizations for Ironic

This folder contains kustomizations for Ironic. They are mainly used
through the [deploy.sh](../tools/deploy.sh) script, which takes care of
through the [deploy-cli](../tools/deploy-cli) library, which takes care of
generating the necessary config for basic-auth and TLS.
However, this executable needs to be built first. To build `deploy-cli`,
run the following command from the project root:

```shell
make deploy-cli
```

- **base** - This is the kustomize base that we start from.
- **components** - In here you will find re-usable kustomize components
Expand Down
322 changes: 322 additions & 0 deletions tools/deploy-cli/deploy-cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
package main

import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"

"embed"
"golang.org/x/crypto/bcrypt"
"regexp"
testexec "sigs.k8s.io/cluster-api/test/framework/exec"
"sigs.k8s.io/kustomize/api/krusty"
"sigs.k8s.io/kustomize/kyaml/filesys"
"text/template"
)

// DeployContext defines the context of the deploy run
type DeployContext struct {
Context context.Context
KubeconfigPath string
// Whether to deploy with basic auth
DeployBasicAuth bool
// Whether to deploy with TLS
DeployTLS bool
// Whether to deploy KeepAlived
DeployKeepAlived bool
// Whether to deploy Mariadb
DeployMariadb bool
// string represents whether to deploy Ironic with RestartContainerCertificateUpdated
RestartContainerCertificateUpdated string
// kustomization overlays
BMOOverlay string
IronicOverlay string
// Endpoint for Ironic
IronicHostIP string
// Username and Password for Ironic authentication
IronicUsername string
IronicPassword string
// Endpoint for Mariadb
MariaDBHostIP string
// Templates to render files using in deployments
TemplateFiles embed.FS
}

// determineIronicAuth determines the username and password configured for ironic
// authentication, following the order:
// - `IRONIC_USERNAME` and `IRONIC_PASSWORD` env var
// - `ironic-username` and `ironic-password` files content
// - Random string
func (d *DeployContext) determineIronicAuth() error {
ironicDataDir := GetEnvOrDefault("IRONIC_DATA_DIR", "/tmp/metal3/ironic/")
ironicAuthDir := filepath.Join(ironicDataDir, "auth")
ironicUsernameFile := filepath.Join(ironicAuthDir, "ironic-username")

if err := os.MkdirAll(ironicAuthDir, 0755); err != nil {
return err
}

ironicUsername, err := getEnvOrFileContent("IRONIC_USERNAME", ironicUsernameFile)
if err != nil && !os.IsNotExist(err) {
return err
}
if ironicUsername == "" {
ironicUsername, err = GenerateRandomString(12)
if err != nil {
return err
}
}
if err = os.WriteFile(ironicUsernameFile, []byte(ironicUsername), 0600); err != nil {
return err
}

ironicPasswordFile := filepath.Join(ironicAuthDir, "ironic-password")
ironicPassword, err := getEnvOrFileContent("IRONIC_PASSWORD", ironicPasswordFile)
if err != nil || ironicPassword == "" {
ironicPassword, err = GenerateRandomString(12)
if err != nil {
return err
}
}
if err = os.WriteFile(ironicPasswordFile, []byte(ironicPassword), 0600); err != nil {
return err
}

d.IronicUsername = ironicUsername
d.IronicPassword = ironicPassword

return nil
}

// deployIronic configures the kustomize overlay for ironic
// based on the configuration, then install ironic with that overlay
func (d *DeployContext) deployIronic() error {
ironicOverlay := d.IronicOverlay

if ironicOverlay == "" {
ironicOverlay, err := MakeRandomDirectory("/tmp/ironic-overlay-", 0755)
if err != nil {
return err
}
ironicKustomizeTpl := "templates/ironic-kustomize.tpl"
kustomizeFile := filepath.Join(ironicOverlay, "kustomization.yaml")

if err := RenderEmbedTemplateToFile(d.TemplateFiles, ironicKustomizeTpl, kustomizeFile, d); err != nil {
return err
}

ironicBMOConfigMapTpl := "templates/ironic_bmo_configmap_env.tpl"
ironicBMOConfigMapOutput := filepath.Join(ironicOverlay, "ironic_bmo_configmap.env")

if err := RenderEmbedTemplateToFile(d.TemplateFiles, ironicBMOConfigMapTpl, ironicBMOConfigMapOutput, d); err != nil {
return err
}

d.IronicOverlay = ironicOverlay
}
ironicOverlay = d.IronicOverlay

username, password := d.IronicUsername, d.IronicPassword

if username != "" && password != "" {
ironicHtpasswd, err := GenerateHtpasswd(username, password)
if err != nil {
return err
}
htpasswdPath := filepath.Join(ironicOverlay, "ironic-htpasswd")
if err = os.WriteFile(htpasswdPath, []byte("IRONIC_HTPASSWD="+ironicHtpasswd), 0600); err != nil {
return err
}
}

return BuildAndApplyKustomization(d.Context, d.KubeconfigPath, d.IronicOverlay)
}

// deployBMO generates the YAML for the Bare Metal Operator using Kustomize
// and applies it to the Kubernetes cluster.
func (d *DeployContext) deployBMO() error {
bmoOverlay := d.BMOOverlay

if bmoOverlay == "" {
bmoOverlay, err := MakeRandomDirectory("/tmp/bmo-overlay-", 0755)
if err != nil {
return err
}
kustomizeTpl := "templates/bmo-kustomize.tpl"
kustomizeFile := filepath.Join(bmoOverlay, "kustomization.yaml")
if err := RenderEmbedTemplateToFile(d.TemplateFiles, kustomizeTpl, kustomizeFile, d); err != nil {
return err
}
ironicEnvTpl := "templates/ironic.env.tpl"
ironicEnvFile := filepath.Join(bmoOverlay, "ironic.env")
if err := RenderEmbedTemplateToFile(d.TemplateFiles, ironicEnvTpl, ironicEnvFile, d); err != nil {
return err
}
d.BMOOverlay = bmoOverlay
}
bmoOverlay = d.BMOOverlay
username, password := d.IronicUsername, d.IronicPassword
if username != "" && password != "" {
usernameFile := filepath.Join(bmoOverlay, "ironic-username")
if err := os.WriteFile(usernameFile, []byte(username), 0600); err != nil {
return err
}
passwordFile := filepath.Join(bmoOverlay, "ironic-password")
if err := os.WriteFile(passwordFile, []byte(password), 0600); err != nil {
return err
}
}

return BuildAndApplyKustomization(d.Context, d.KubeconfigPath, d.BMOOverlay)
}

// cleanup removes temporary files created for basic auth credentials during deployment.
func (d *DeployContext) cleanup() error {
tempFiles := []string{
filepath.Join(d.BMOOverlay, "ironic-username"),
filepath.Join(d.BMOOverlay, "ironic-password"),
filepath.Join(d.IronicOverlay, "ironic-auth-config"),
filepath.Join(d.IronicOverlay, "ironic-htpasswd"),
}
for _, tempFile := range tempFiles {
if err := os.Remove(tempFile); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}

// GetEnvOrDefault returns the value of the environment variable key if it exists
// and is non-empty. Otherwise it returns the provided default value.
func GetEnvOrDefault(key, defaultValue string) string {
value, exists := os.LookupEnv(key)
if exists && value != "" {
return value
}

return defaultValue
}

// GenerateHtpasswd generates a htpasswd entry for the given username and password.
func GenerateHtpasswd(username, password string) (string, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}

return fmt.Sprintf("%s:%s", username, string(hashedPassword)), nil
}

// GenerateRandomString generates random string of given length
// using crypto/rand and base64 encoding.
func GenerateRandomString(length int) (string, error) {
b := make([]byte, length)
_, err := rand.Read(b)
if err != nil {
return "", fmt.Errorf("failed to generate random string: %v", err)
}

return base64.RawURLEncoding.EncodeToString(b)[:length], nil
}

// getEnvOrFileContent checks for an environment variable;
// if the env var is not present, reads the content of a file and return
func getEnvOrFileContent(varName, filePath string) (string, error) {
val, exists := os.LookupEnv(varName)
if exists && val != "" {
log.Printf("[%s] Using value from environment variable", varName)
return val, nil
}
content, err := os.ReadFile(filePath)
if err != nil {
return "", err
}

return string(content), nil
}

// MakeRandomDirectory generates a new directory whose name starts with the
// provided prefix and ends with a random string
func MakeRandomDirectory(prefix string, perm os.FileMode) (string, error) {
randomStr, err := GenerateRandomString(6)
if err != nil {
return "", err
}
randomDir := prefix + randomStr
if err := os.RemoveAll(randomDir); err != nil {
return "", err
}
if err := os.MkdirAll(randomDir, perm); err != nil {
return "", err
}
return randomDir, nil
}

// RenderEmbedTemplateToFile reads in a go-template, renders it with supporting data
// and then write the result to an output file
func RenderEmbedTemplateToFile(templateFiles embed.FS, inputFile, outputFile string, data interface{}) error {
tmpl, err := template.ParseFS(templateFiles, inputFile)
if err != nil {
return err
}
f, err := os.Create(outputFile)
if err != nil {
return err
}
defer f.Close()

if err = tmpl.Execute(f, data); err != nil {
return err
}

return nil
}

// GetKubeconfigPath returns the path to the kubeconfig file.
func GetKubeconfigPath() string {
// Check KUBECTL_ARGS env var
kubectlArgs, exists := os.LookupEnv("KUBECTL_ARGS")
if exists {
re := regexp.MustCompile(`--kubeconfig=([^\s]+)`)
match := re.FindStringSubmatch(kubectlArgs)
if len(match) > 1 {
return match[1]
}
}
kubeconfigPath := os.Getenv("KUBECONFIG")
if kubeconfigPath == "" {
kubeconfigPath = os.Getenv("HOME") + "/.kube/config"
}
return kubeconfigPath
}

// NOTE: The following functions are almost identical to the same functions in BMO e2e
// They can be removed and imported from BMO E2E after the next BMO release
func BuildKustomizeManifest(source string) ([]byte, error) {
kustomizer := krusty.MakeKustomizer(krusty.MakeDefaultOptions())
fSys := filesys.MakeFsOnDisk()
resources, err := kustomizer.Run(fSys, source)
if err != nil {
return nil, err
}
return resources.AsYaml()
}

// BuildAndApplyKustomization builds the provided kustomization
// and apply it to the cluster provided by clusterProxy.
func BuildAndApplyKustomization(ctx context.Context, kubeconfigPath string, kustomization string) error {
var err error
manifest, err := BuildKustomizeManifest(kustomization)
if err != nil {
return err
}

if err = testexec.KubectlApply(ctx, kubeconfigPath, manifest); err != nil {
return err
}
return nil
}
Loading

0 comments on commit 8d7c6cf

Please sign in to comment.