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 May 8, 2024
1 parent d3376d8 commit ed78139
Show file tree
Hide file tree
Showing 10 changed files with 1,538 additions and 2 deletions.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,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
324 changes: 324 additions & 0 deletions tools/deploy-cli/deploy-cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
package main

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

"embed"

"golang.org/x/crypto/bcrypt"
"net/http"
"sigs.k8s.io/cluster-api/test/framework"
testexec "sigs.k8s.io/cluster-api/test/framework/exec"
"text/template"

"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/kustomize/api/krusty"
"sigs.k8s.io/kustomize/kyaml/filesys"
)

// DeployContext defines the context of the deploy run
type DeployContext struct {
// 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
// Endpoint for Ironic
IronicHostIP string
// Endpoint for Mariadb
MariaDBHostIP string
// Templates to render files using in deployments
TemplateFiles embed.FS
}

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

// DownloadFile downloads a file and stores its content to a specified location on disk
func DownloadFile(url string, filepath string) error {
// Create the file
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()

// Get the data
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()

// Write the body to file
_, err = io.Copy(out, resp.Body)
return err
}

// EnsureFileExists checks if a file exists at path, creating it with random content if it doesn't.
func EnsureFileExists(varName, path string, length int) (bool, error) {
_, err := os.Stat(path)
if os.IsNotExist(err) {
generatedString, err := GenerateRandomString(length)
if err != nil {
return false, err
}

err = os.WriteFile(path, []byte(generatedString), 0600)
if err != nil {
return false, err
}
log.Printf("[%s] Created new file with random content at: %s", varName, path)
return true, nil
}

return false, nil
}

// ReadFileContent reads and returns the content of the file at path.
func ReadFileContent(varName, path string) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("[%s] failed to read file %s: %v", varName, path, err)
}

return string(content), nil
}

// getEnvOrFileContent checks for an environment variable; if not present, ensures a file exists and reads it.
func getEnvOrFileContent(varName, filePath string, length int) (string, error) {
val, exists := os.LookupEnv(varName)
if exists && val != "" {
log.Printf("[%s] Using value from environment variable", varName)
return val, nil
}

newlyCreated, err := EnsureFileExists(varName, filePath, length)
if err != nil {
return "", err
}

if !newlyCreated {
log.Printf("[%s] Reading content from existing file: %s", varName, filePath)
}

content, err := ReadFileContent(varName, filePath)
if err != nil {
return "", err
}

return content, nil
}

// BuildKustomizeManifest builds a provided kustomize overlays to output, same as `kustomize build`
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()
}

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

// deployIronic generates the YAML for Ironic using Kustomize and applies it to the Kubernetes cluster.
func deployIronic(data *DeployContext, tempIronicOverlay string) error {
if data.IronicHostIP == "" {
return fmt.Errorf("failed to determine IRONIC_HOST_IP")
}
ironicKustomizeTpl := "templates/ironic-kustomize.tpl"
kustomizeFile := filepath.Join(tempIronicOverlay, "kustomization.yaml")

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

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

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

return deployWithKustomizeAndApply(tempIronicOverlay)
}

// deployBMO generates the YAML for the Bare Metal Operator using Kustomize
// and applies it to the Kubernetes cluster.
func deployBMO(data *DeployContext, tempBMOOverlay string) error {

inputFile := "templates/bmo-kustomize.tpl"
kustomizeFile := filepath.Join(tempBMOOverlay, "kustomization.yaml")
if err := RenderEmbedTemplateToFile(data.TemplateFiles, inputFile, kustomizeFile, data); err != nil {
return err
}
ironicEnvSrc := "https://raw.githubusercontent.com/metal3-io/baremetal-operator/main/config/default/ironic.env"
ironicEnvDst := filepath.Join(tempBMOOverlay, "ironic.env")
if err := DownloadFile(ironicEnvSrc, ironicEnvDst); err != nil {
return err
}

return deployWithKustomizeAndApply(tempBMOOverlay)
}

// getKubeconfigPath determines the kubeconfig path from KUBECTL_ARGS, KUBECONFIG_PATH, or defaults to ~/.kube/config.
func getKubeconfigPath() (string, error) {
kubectlArgs := os.Getenv("KUBECTL_ARGS")
kubeconfigPrefix := "--kubeconfig="
regexPattern := regexp.MustCompile(`^--kubeconfig=[\w/.-]+$`)
var kubeconfigPath string

if strings.Contains(kubectlArgs, kubeconfigPrefix) && regexPattern.MatchString(kubectlArgs) {
kubeconfigPath = strings.TrimPrefix(kubectlArgs, kubeconfigPrefix)
} else if kubectlArgs != "" {
return "", fmt.Errorf("error: invalid format in KUBECTL_ARGS. Expected format: '--kubeconfig=/path/to/kubeconfig'")
}

if kubeconfigPath == "" {
kubeconfigPath = os.Getenv("KUBECONFIG_PATH")
if kubeconfigPath == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get user home directory: %v", err)
}
kubeconfigPath = filepath.Join(homeDir, ".kube", "config")
}
}

// Verify the file exists and is readable
_, err := os.Stat(kubeconfigPath)
if os.IsNotExist(err) || err != nil {
return "", fmt.Errorf("specified kubeconfig file does not exist or is not readable: %s", kubeconfigPath)
}

return kubeconfigPath, nil
}

// generateClusterProxy creates a new ClusterProxy instance using the provided kubeconfig path.
func generateClusterProxy(kubeconfigPath string) (*framework.ClusterProxy, error) {
scheme := runtime.NewScheme()
framework.TryAddDefaultSchemes(scheme)
clusterProxy := framework.NewClusterProxy("deploy-cli", kubeconfigPath, scheme)
if clusterProxy == nil {
return nil, fmt.Errorf("failed to create cluster proxy")
}
return &clusterProxy, nil
}

// deployWithKustomizeAndApply first generates the YAML configuration by running Kustomize build on the overlay directory,
// then outputs the generated YAML to a temp file within the overlay dir, and then applies
// the configuration to the Kubernetes cluster specified in the kubeconfig file.
func deployWithKustomizeAndApply(overlayPath string) error {
yamlOutput, err := BuildKustomizeManifest(overlayPath)

if err != nil {
return fmt.Errorf("failed to apply YAML: %v", err)
}

kubeconfigPath, err := getKubeconfigPath()
if err != nil {
return fmt.Errorf("failed to apply YAML: %v", err)
}

ctx := context.Background()
if err := testexec.KubectlApply(ctx, kubeconfigPath, yamlOutput); err != nil {
return fmt.Errorf("failed to apply YAML: %v", err)
}

return nil
}

// cleanup removes temporary files created for basic auth credentials during deployment.
func cleanup(deployBasicAuthFlag, deployBMOFlag, deployIronicFlag bool, tempBMOOverlay, tempIronicOverlay string) {
if deployBasicAuthFlag {
if deployBMOFlag {
os.Remove(filepath.Join(tempBMOOverlay, "ironic-username"))
os.Remove(filepath.Join(tempBMOOverlay, "ironic-password"))
os.Remove(filepath.Join(tempBMOOverlay, "ironic-inspector-username"))
os.Remove(filepath.Join(tempBMOOverlay, "ironic-inspector-password"))
}

if deployIronicFlag {
os.Remove(filepath.Join(tempIronicOverlay, "ironic-auth-config"))
os.Remove(filepath.Join(tempIronicOverlay, "ironic-inspector-auth-config"))
os.Remove(filepath.Join(tempIronicOverlay, "ironic-htpasswd"))
os.Remove(filepath.Join(tempIronicOverlay, "ironic-inspector-htpasswd"))
}
}
}

func usage() {
fmt.Println(`Usage : deploy [options]
Options:
-h: show this help message
-b: deploy BMO
-i: deploy Ironic
-t: deploy with TLS enabled
-n: deploy without authentication
-k: deploy with keepalived
-m: deploy with mariadb (requires TLS enabled)`)
}
Loading

0 comments on commit ed78139

Please sign in to comment.