cli/azd/pkg/tools/kubectl/kubectl.go (254 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package kubectl
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"text/template"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
)
var _ tools.ExternalTool = (*Cli)(nil)
type OutputType string
const (
OutputTypeJson OutputType = "json"
OutputTypeYaml OutputType = "yaml"
)
type DryRunType string
const (
DryRunTypeNone DryRunType = "none"
// If client strategy, only print the object that would be sent
DryRunTypeClient DryRunType = "client"
// If server strategy, submit server-side request without persisting the resource.
DryRunTypeServer DryRunType = "server"
)
// K8s CLI Fags
type KubeCliFlags struct {
// The namespace to filter the command or create resources
Namespace string
// The dry-run type, defaults to empty
DryRun DryRunType
// The expected output, typically JSON or YAML
Output OutputType
}
// templateRoot is the structure of the template available within the test templates that can be used within k8s manifests.
// We have the option to include additional nodes within the template in the future for things like config, etc
type templateRoot struct {
// The Azd environment variables
Env map[string]string
}
type Cli struct {
commandRunner exec.CommandRunner
env map[string]string
cwd string
}
// Creates a new K8s CLI instance
func NewCli(commandRunner exec.CommandRunner) *Cli {
return &Cli{
commandRunner: commandRunner,
env: map[string]string{},
}
}
// Checks whether or not the K8s CLI is installed and available within the PATH
func (cli *Cli) CheckInstalled(ctx context.Context) error {
if err := tools.ToolInPath("kubectl"); err != nil {
return err
}
// We don't have a minimum required version of kubectl today, but
// for diagnostics purposes, let's fetch and log the version of kubectl
// we're using.
if ver, err := cli.getClientVersion(ctx); err != nil {
log.Printf("error fetching kubectl version: %s", err)
} else {
log.Printf("kubectl version: %s", ver)
}
return nil
}
func (cli *Cli) getClientVersion(ctx context.Context) (string, error) {
versionRes, err := cli.Exec(ctx, &KubeCliFlags{Output: "json"}, "version", "--client=true")
if err != nil {
return "", fmt.Errorf("fetching kubectl version: %w", err)
}
var versionObj struct {
ClientVersion struct {
GitVersion string `json:"gitVersion"`
} `json:"clientVersion"`
}
if err := json.Unmarshal([]byte(versionRes.Stdout), &versionObj); err != nil {
return "", fmt.Errorf("parsing kubectl version output: %w", err)
}
return versionObj.ClientVersion.GitVersion, nil
}
// Returns the installation URL to install the K8s CLI
func (cli *Cli) InstallUrl() string {
return "https://aka.ms/azure-dev/kubectl-install"
}
// Gets the name of the Tool
func (cli *Cli) Name() string {
return "kubectl"
}
// Sets the env vars available to the CLI
func (cli *Cli) SetEnv(envValues map[string]string) {
for key, value := range envValues {
cli.env[key] = value
}
}
// Sets the KUBECONFIG environment variable
func (cli *Cli) SetKubeConfig(kubeConfig string) {
cli.env[KubeConfigEnvVarName] = kubeConfig
}
// Sets the current working directory
func (cli *Cli) Cwd(cwd string) {
cli.cwd = cwd
}
// Sets the k8s context to use for future CLI commands
func (cli *Cli) ConfigUseContext(ctx context.Context, name string, flags *KubeCliFlags) (*exec.RunResult, error) {
res, err := cli.Exec(ctx, flags, "config", "use-context", name)
if err != nil {
return nil, fmt.Errorf("failed setting kubectl context: %w", err)
}
return &res, nil
}
// Views the current k8s configuration including available clusters, contexts & users
func (cli *Cli) ConfigView(
ctx context.Context,
merge bool,
flatten bool,
flags *KubeCliFlags,
) (*exec.RunResult, error) {
kubeConfigDir, err := getKubeConfigDir()
if err != nil {
return nil, err
}
args := []string{"config", "view"}
if merge {
args = append(args, "--merge")
}
if flatten {
args = append(args, "--flatten")
}
runArgs := exec.NewRunArgs("kubectl", args...).
WithCwd(kubeConfigDir)
res, err := cli.executeCommandWithArgs(ctx, runArgs, flags)
if err != nil {
return nil, fmt.Errorf("kubectl config view: %w", err)
}
return &res, nil
}
func (cli *Cli) ApplyWithStdIn(ctx context.Context, input string, flags *KubeCliFlags) (*exec.RunResult, error) {
runArgs := exec.
NewRunArgs("kubectl", "apply", "-f", "-").
WithStdIn(strings.NewReader(input))
res, err := cli.executeCommandWithArgs(ctx, runArgs, flags)
if err != nil {
return nil, fmt.Errorf("kubectl apply -f: %w", err)
}
return &res, nil
}
func (cli *Cli) ApplyWithFile(ctx context.Context, filePath string, flags *KubeCliFlags) (*exec.RunResult, error) {
runArgs := exec.NewRunArgs("kubectl", "apply", "-f", filePath)
res, err := cli.executeCommandWithArgs(ctx, runArgs, flags)
if err != nil {
return nil, fmt.Errorf("kubectl apply -f: %w", err)
}
return &res, nil
}
// Applies manifests from the specified input
func (cli *Cli) Apply(ctx context.Context, path string, flags *KubeCliFlags) error {
if err := cli.applyTemplates(ctx, path, flags); err != nil {
return fmt.Errorf("failed process templates, %w", err)
}
return nil
}
// Applies the manifests at the specified path using kustomize
func (cli *Cli) ApplyWithKustomize(ctx context.Context, path string, flags *KubeCliFlags) error {
runArgs := exec.NewRunArgs("kubectl", "apply", "-k", path)
_, err := cli.executeCommandWithArgs(ctx, runArgs, flags)
if err != nil {
return fmt.Errorf("failing running kubectl apply -k: %w", err)
}
return nil
}
// Creates a new k8s namespace with the specified name
func (cli *Cli) CreateNamespace(ctx context.Context, name string, flags *KubeCliFlags) (*exec.RunResult, error) {
args := []string{"create", "namespace", name}
res, err := cli.Exec(ctx, flags, args...)
if err != nil {
return nil, fmt.Errorf("kubectl create namespace: %w", err)
}
return &res, nil
}
// Gets the deployment rollout status
func (cli *Cli) RolloutStatus(
ctx context.Context,
deploymentName string,
flags *KubeCliFlags,
) (*exec.RunResult, error) {
res, err := cli.Exec(ctx, flags, "rollout", "status", fmt.Sprintf("deployment/%s", deploymentName))
if err != nil {
return nil, fmt.Errorf("deployment rollout failed, %w", err)
}
return &res, nil
}
// Executes a k8s CLI command from the specified arguments and flags
func (cli *Cli) Exec(ctx context.Context, flags *KubeCliFlags, args ...string) (exec.RunResult, error) {
runArgs := exec.
NewRunArgs("kubectl").
AppendParams(args...)
return cli.executeCommandWithArgs(ctx, runArgs, flags)
}
func (cli *Cli) applyTemplate(ctx context.Context, filePath string, flags *KubeCliFlags) (*exec.RunResult, error) {
k8sTemplate, err := template.ParseFiles(filePath)
if err != nil {
return nil, fmt.Errorf("failed parsing template file '%s', %w", filePath, err)
}
builder := strings.Builder{}
err = k8sTemplate.Execute(&builder, templateRoot{Env: cli.env})
if err != nil {
return nil, fmt.Errorf("failed executing template file '%s', %w", filePath, err)
}
result, err := cli.ApplyWithStdIn(ctx, builder.String(), flags)
if err != nil {
return nil, fmt.Errorf("failed applying file '%s', %w", filePath, err)
}
return result, nil
}
// Recursively loops through the specified directory and applies all k8s manifests
// If the file is a *.tmpl file, it will be parsed as a template to support environment injection.
// Otherwise the actual file contents will be applied.
func (cli *Cli) applyTemplates(ctx context.Context, directoryPath string, flags *KubeCliFlags) error {
entries, err := os.ReadDir(directoryPath)
if err != nil {
return fmt.Errorf("failed reading files in path, '%s', %w", directoryPath, err)
}
for _, entry := range entries {
entryPath := filepath.Join(directoryPath, entry.Name())
if entry.IsDir() {
if err := cli.applyTemplates(ctx, entryPath, flags); err != nil {
return fmt.Errorf("failed applying templates at '%s', %w", entryPath, err)
}
continue
}
ext := filepath.Ext(entry.Name())
var err error
switch ext {
case ".yaml", ".yml": // Only include yaml files
fileNameWithoutExtension := strings.TrimSuffix(entry.Name(), ext)
isTemplateFile := strings.HasSuffix(fileNameWithoutExtension, ".tmpl")
if isTemplateFile {
_, err = cli.applyTemplate(ctx, entryPath, flags)
} else {
_, err = cli.ApplyWithFile(ctx, entryPath, flags)
}
default: // Ignore all other files
continue
}
if err != nil {
return fmt.Errorf("failed applying file '%s', %w", entryPath, err)
}
}
return nil
}
func (cli *Cli) executeCommandWithArgs(
ctx context.Context,
args exec.RunArgs,
flags *KubeCliFlags,
) (exec.RunResult, error) {
if cli.cwd != "" {
args = args.WithCwd(cli.cwd)
}
args = args.WithEnv(environ(cli.env))
if flags != nil {
if flags.DryRun != "" {
args = args.AppendParams(fmt.Sprintf("--dry-run=%s", flags.DryRun))
}
if flags.Namespace != "" {
args = args.AppendParams("-n", flags.Namespace)
}
if flags.Output != "" {
args = args.AppendParams("-o", string(flags.Output))
}
}
return cli.commandRunner.Run(ctx, args)
}
func environ(values map[string]string) []string {
env := []string{}
for key, value := range values {
env = append(env, fmt.Sprintf("%s=%s", key, value))
}
return env
}