Skip to content
Snippets Groups Projects
main.go 12.4 KiB
Newer Older
Florent Aide's avatar
Florent Aide committed
package runner

	"bytes"
	"io"
	"os"
	"path/filepath"
	"strings"

	"github.com/go-cmd/cmd"
var (
	defaultFileMod os.FileMode = 0600
	defaultDirMod  os.FileMode = 0700
steeve.chailloux's avatar
steeve.chailloux committed
	// TODO: find commands full path
	yttCmd     = "ytt"
	helmCmd    = "helm"
	kubectlCmd = "kubectl"
// Runner is the struct in charge of launching commands
Florent Aide's avatar
Florent Aide committed
type Runner struct {
	config *CmdConfig
Florent Aide's avatar
Florent Aide committed
}

// NewRunner ...
func NewRunner(cfg *CmdConfig) *Runner {
Florent Aide's avatar
Florent Aide committed
	return &Runner{
		config: cfg,
	}
}

// Build is in charge of applying commands based on the config data
func (r *Runner) Build(tmpDir string) error {
	variables, err := r.config.prepareVariables(false)
	if err != nil {
		return fmt.Errorf("cannot prepare variables: %w", err)
	}
steeve.chailloux's avatar
steeve.chailloux committed
	var outputDir string
	if r.config.Output == "" {
		w := bytes.NewBuffer([]byte{})
		if err := hydrateString(r.config.Namespace, w, variables); err != nil {
			return err
		}
		r.config.Namespace = w.String()
steeve.chailloux's avatar
steeve.chailloux committed
		outputDir = filepath.Join(r.config.RootDir, "build", r.config.Namespace)
	} else {
		outputDir = r.config.Output
	}
	for name := range r.config.Spec.Charts {
		w := bytes.NewBuffer([]byte{})
		if err := hydrateString(r.config.Spec.Charts[name].Disabled, w, variables); err != nil {
			return err
		}
		chart := r.config.Spec.Charts[name]
		chart.Disabled = w.String()
		r.config.Spec.Charts[name] = chart
	}
steeve.chailloux's avatar
steeve.chailloux committed
	preBuildDir := filepath.Join(tmpDir, "pre-build")
	if err := r.DoBuild(tmpDir, preBuildDir); err != nil {
		return fmt.Errorf("failed to do pre-build: %w", err)
	}
	if err := r.config.SetShas(preBuildDir); err != nil {
		return fmt.Errorf("failed to set SHAs: %w", err)
	}
	files, err := os.ReadDir(preBuildDir)
steeve.chailloux's avatar
steeve.chailloux committed
	if err != nil {
		return fmt.Errorf("cannot list directory: %s - %w", preBuildDir, err)
	}
	if outputDir != "stdout" {
		if err := CleanDir(outputDir); err != nil {
			return fmt.Errorf("cannot clean dir: %s: %w", outputDir, err)
		}
steeve.chailloux's avatar
steeve.chailloux committed
	}
	variables, err = r.config.prepareVariables(true)
steeve.chailloux's avatar
steeve.chailloux committed
	if err != nil {
		return fmt.Errorf("cannot prepare variables: %w", err)
	}
	for _, file := range files {
		var outFilePath string
		var outFile *os.File
steeve.chailloux's avatar
steeve.chailloux committed
		inFilePath := filepath.Join(preBuildDir, file.Name())
		if outputDir == "stdout" {
			outFilePath = "stdout"
			outFile = os.Stdout
		} else {
			var outputFileName bytes.Buffer
			if err := Hydrate([]byte(file.Name()), &outputFileName, variables); err != nil {
				return fmt.Errorf("cannot hydrate file name: %w", err)
			}
			outFilePath = filepath.Join(outputDir, strings.TrimSuffix(outputFileName.String(), "\n"))
			outFile, err = os.Create(outFilePath)
			if err != nil {
				return fmt.Errorf("cannot open: %s - %w", outFilePath, err)
			}
			defer func() {
				if err := outFile.Close(); err != nil {
					r.config.Logger.Fatal().Err(err).Msg("cannot close hydrated file")
				}
			}()
steeve.chailloux's avatar
steeve.chailloux committed
		}
steeve.chailloux's avatar
steeve.chailloux committed
		if err := hydrate(inFilePath, outFile, variables); err != nil {
			return fmt.Errorf("cannot hydrate: %s - %w", outFilePath, err)
steeve.chailloux's avatar
steeve.chailloux committed
	return nil
steeve.chailloux's avatar
steeve.chailloux committed
}

func (r *Runner) DoBuild(tmpDir, outputDir string) error {
steeve.chailloux's avatar
steeve.chailloux committed
	cmds, err := r.prepareCmds()
	if err != nil {
		return err
	}

	compiled, err := r.runCommands(tmpDir, cmds)
	if err != nil {
		return err
	}

	yttOutput, err := r.runYtt(tmpDir, compiled)
	if err != nil {
		return err
	}

	kustomizeOutput, err := r.kustomize(tmpDir, yttOutput)
	if err != nil {
		return err
	}

	if r.config.DryRun {
		return nil
	}

	if err := CleanDir(outputDir); err != nil {
		return fmt.Errorf("cannot clean dir: %s: %w", outputDir, err)
	}
	if _, err := YamlSplit(outputDir, kustomizeOutput.Name()); err != nil {
		return fmt.Errorf("cannot split full compiled file: %w", err)
	}

	return nil
}

func (r *Runner) kustomize(tmpDir string, input *os.File) (*os.File, error) {
	kustomizeFilePath := filepath.Join(tmpDir, "kustomization.yaml")
	f, err := os.Create(kustomizeFilePath)
	if err != nil {
		return nil, fmt.Errorf("fail to open %s: %w", kustomizeFilePath, err)
	}
	_, err = f.Write([]byte(fmt.Sprintf("resources: [%s]", filepath.Base(input.Name()))))
	if err != nil {
		return nil, fmt.Errorf("fail to write %s: %w", kustomizeFilePath, err)
	}

	variables, err := r.config.prepareVariables(false)
	if err != nil {
		return nil, fmt.Errorf("cannot prepare kustomize variables: %w", err)
	}

	var lastKustomizeFolder string
steeve.chailloux's avatar
steeve.chailloux committed
	for _, layer := range r.config.Layers {
		for _, ext := range []string{"yml", "yaml"} {
			fName := fmt.Sprintf("kustomization.%s", ext)
			fPath := filepath.Join(layer, "kustomize", fName)

			fStat, err := os.Stat(fPath)
			if err != nil || fStat.IsDir() {
				continue
			}
			backupFile := fmt.Sprintf("%s.back", fPath)
			if err := Copy(fPath, backupFile); err != nil {
				return nil, fmt.Errorf("cannot copy kustomization file: %w", err)
			}
			defer func(fPath string) {
				if err := Copy(backupFile, fPath); err != nil {
					r.config.Logger.Fatal().Err(err).Msg("cannot restore kustomization back file")
				}
				if err := os.Remove(backupFile); err != nil {
					r.config.Logger.Fatal().Err(err).Msg("cannot remove kustomization back file")
				}
			}(fPath)

			if err := os.Remove(fPath); err != nil {
				return nil, fmt.Errorf("cannot remove original kustomization file: %w", err)
			}
			outFile, err := os.Create(fPath)
			if err != nil {
				return nil, fmt.Errorf("cannot open: %s - %w", fPath, err)
			}
			defer func() {
				if err := outFile.Close(); err != nil {
					r.config.Logger.Fatal().Err(err).Msg("cannot close hydrated kustomization file")
				}
			}()

			// kustomize root cannot be absolute
			RelInputFilePath, err := filepath.Rel(filepath.Join(layer, "kustomize"), tmpDir)
			if err != nil {
				return nil, fmt.Errorf("cannot find relative path for: %s - %w", tmpDir, err)
			}
			variables["beaver"] = map[string]interface{}{
				"build": RelInputFilePath,
			}
steeve.chailloux's avatar
steeve.chailloux committed

			if err := hydrate(backupFile, outFile, variables); err != nil {
				return nil, fmt.Errorf("cannot hydrate: %s - %w", fPath, err)
steeve.chailloux's avatar
steeve.chailloux committed
			}
			lastKustomizeFolder = filepath.Join(layer, "kustomize")
		}
	}

	// now run customize on the last layer with a kustomize folder
	if lastKustomizeFolder != "" {
		// now run customize on the last layer with a kustomize folder
		kustomizeCmd := cmd.NewCmd(kubectlCmd, []string{"kustomize", lastKustomizeFolder}...)
		return r.runCommand(tmpDir, "kustomize", kustomizeCmd)
	}

	return input, nil
}

func toBool(s string) (bool, error) {
	sLower := strings.ToLower(s)
	finalString := strings.TrimSuffix(sLower, "\n")
	switch finalString {
	case "0", "false", "":
		return false, nil
	case "1", "true":
		return true, nil
	default:
		return false, errors.New("cannot parse " + s + " as bool")
	}
}

steeve.chailloux's avatar
steeve.chailloux committed
func (r *Runner) prepareCmds() (map[string]*cmd.Cmd, error) {
	// create helm commands
	// create ytt chart commands
	cmds := make(map[string]*cmd.Cmd)
	for name, chart := range r.config.Spec.Charts {
		disabled, err := toBool(chart.Disabled)
		if err != nil {
			return nil, err
		}
		if disabled {
			continue
		}
		args, err := chart.BuildArgs(name, r.config.Namespace)
		if err != nil {
steeve.chailloux's avatar
steeve.chailloux committed
			return nil, fmt.Errorf("build: failed to build args %w", err)
		switch chart.Type {
		case HelmType:
			cmds[name] = cmd.NewCmd(helmCmd, args...)
		case YttType:
			cmds[name] = cmd.NewCmd(yttCmd, args...)
steeve.chailloux's avatar
steeve.chailloux committed
			return nil, fmt.Errorf("unsupported chart %s type: %q", chart.Path, chart.Type)

	for key, create := range r.config.Spec.Creates {
		strArgs := key.BuildArgs(r.config.Namespace, create.Args)
		name := fmt.Sprintf("%s_%s", key.Type, key.Name)
		c := cmd.NewCmd(kubectlCmd, strArgs...)
		c.Dir = create.Dir
		cmds[name] = c
steeve.chailloux's avatar
steeve.chailloux committed
	return cmds, nil
}
func (r *Runner) runCommand(tmpDir, name string, cmd *cmd.Cmd) (*os.File, error) {
	tmpFile, err := os.CreateTemp(tmpDir, fmt.Sprintf("compiled-%s-*.yaml", name))
	if err != nil {
		return nil, fmt.Errorf("cannot create compiled file: %w", err)
	}
	defer func() {
		if err := tmpFile.Close(); err != nil {
			r.config.Logger.
				Err(err).
				Str("temp file", tmpFile.Name()).
				Msg("failed to close temp file")
		}
	}()
	if r.config.DryRun {
steeve.chailloux's avatar
steeve.chailloux committed
		r.config.Logger.Info().
			Str("command", cmd.Name).
			Strs("args", cmd.Args).
steeve.chailloux's avatar
steeve.chailloux committed
			Msg("would run command")
	r.config.Logger.Debug().
		Str("command", cmd.Name).
		Strs("args", cmd.Args).
		Msg("running command")
steeve.chailloux's avatar
steeve.chailloux committed
	stdOut, stdErr, err := RunCMD(cmd)
	if err != nil {
		r.config.Logger.Err(err).
			Str("command", cmd.Name).
			Str("args", strings.Join(cmd.Args, " ")).
			Str("sdtout", strings.Join(stdOut, "\n")).
			Str("stderr", strings.Join(stdErr, "\n")).
			Msg("failed to run command")
steeve.chailloux's avatar
steeve.chailloux committed
		// Error must be pretty printed to end users /!\
		fmt.Printf("\n%s\n\n", strings.Join(stdErr, "\n"))
		return nil, fmt.Errorf("failed to run command: %w", err)
	}
	if _, err := tmpFile.WriteString(strings.Join(stdOut, "\n")); err != nil {
		return nil, fmt.Errorf("cannot write compiled file: %w", err)
steeve.chailloux's avatar
steeve.chailloux committed
	return tmpFile, nil
}
steeve.chailloux's avatar
steeve.chailloux committed
func (r *Runner) runCommands(tmpDir string, cmds map[string]*cmd.Cmd) ([]string, error) {
	var compiled []string
	for name, cmd := range cmds {
		f, err := r.runCommand(tmpDir, name, cmd)
		if err != nil {
			return nil, err
		}
		compiled = append(compiled, f.Name())
	}
	return compiled, nil
}

func (r *Runner) runYtt(tmpDir string, compiled []string) (*os.File, error) {
	// create ytt additional command
	args := r.config.BuildYttArgs(r.config.Spec.Ytt, compiled)
	yttExtraCmd := cmd.NewCmd(yttCmd, args...)
steeve.chailloux's avatar
steeve.chailloux committed
	return r.runCommand(tmpDir, "ytt", yttExtraCmd)
Florent Aide's avatar
Florent Aide committed
}
steeve.chailloux's avatar
steeve.chailloux committed
func CleanDir(directory string) error {
	if err := os.RemoveAll(directory); err != nil {
		return fmt.Errorf("cannot cleanup output directory: %w", err)
	}
	if err := os.MkdirAll(directory, defaultDirMod); err != nil {
		return fmt.Errorf("cannot create output directory: %w", err)
	}
	return nil
}

steeve.chailloux's avatar
steeve.chailloux committed
func Copy(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()

	out, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer out.Close()

	_, err = io.Copy(out, in)
	if err != nil {
		return err
	}
	return out.Close()
}

// YamlSplit takes a buildDir and an inputFile.
// it returns a list of yaml documents and an eventual error
func YamlSplit(buildDir, inputFile string) ([]string, error) {
	var docs []string
	var allResources []map[string]interface{}
	input, err := os.ReadFile(inputFile)
	if err != nil {
		return nil, err
	}
	if err := unmarshalAllResources(input, &allResources); err != nil {
		return nil, err
	}
	for _, resource := range allResources {
		apiVersionData, ok := resource["apiVersion"]
			return nil, fmt.Errorf("apiVersion not present in resource: %+v", resource)
		}
		apiVersion, ok := apiVersionData.(string)
		if !ok {
			return nil, fmt.Errorf("failed to type assert apiVersion to string from: %+v", apiVersionData)
		}
		kind, ok := resource["kind"].(string)
		if !ok {
			return nil, fmt.Errorf("kind missing from: %+v", resource)
		}
		metadata, ok := resource["metadata"].(map[string]interface{})
		if !ok {
			return nil, fmt.Errorf("fail to type assert metadata from: %+v", resource)
		}
		namespace, ok := metadata["namespace"]
		if !ok {
			namespace = ""
		}
		name, ok := metadata["name"]
			return nil, fmt.Errorf("fail to type get metadata.name from: %+v", resource)
		filename := ""
		if namespace != "" {
			filename = fmt.Sprintf("%s.%s.%s.%s.yaml", kind, strings.ReplaceAll(apiVersion, "/", "_"), namespace, name)
		} else {
			filename = fmt.Sprintf("%s.%s.%s.yaml", kind, strings.ReplaceAll(apiVersion, "/", "_"), name)
		}
		fPath := filepath.Join(buildDir, filename)
		buf := new(bytes.Buffer)
		encoder := yaml.NewEncoder(buf)
		encoder.SetIndent(2)
		if err := encoder.Encode(resource); err != nil {
			return nil, fmt.Errorf("cannot encode resource: %+v, %w", resource, err)
		/*
			out, err := yaml.Marshal(resource)
			if err != nil {
				return nil, fmt.Errorf("cannot marshal resource: %w", err)
			}
		*/
		if err := os.MkdirAll(buildDir, defaultDirMod); err != nil {
			return nil, fmt.Errorf("cannot create build directory: %w", err)
		content := append([]byte("---\n"), buf.Bytes()...)
		if err := os.WriteFile(fPath, content, defaultFileMod); err != nil {
			return nil, fmt.Errorf("cannot write resource: %w", err)
		}
		docs = append(docs, fPath)
	return docs, nil
func unmarshalAllResources(in []byte, out *[]map[string]interface{}) error {
	r := bytes.NewReader(in)
	decoder := yaml.NewDecoder(r)
	for {
		res := make(map[string]interface{})
		if err := decoder.Decode(&res); err != nil {
			// Break when there are no more documents to decode
			if !errors.Is(err, io.EOF) {
				return err
			}
			break
		}
		*out = append(*out, res)
	}
	return nil
}