package runner import ( "bytes" "errors" "fmt" "io" "io/ioutil" "os" "path/filepath" "strings" "github.com/go-cmd/cmd" "github.com/go-yaml/yaml" ) var ( defaultFileMod os.FileMode = 0600 defaultDirMod os.FileMode = 0700 ) // Runner is the struct in charge of launching commands type Runner struct { config *CmdConfig } // NewRunner ... func NewRunner(cfg *CmdConfig) *Runner { return &Runner{ config: cfg, } } // Build is in charge of applying commands based on the config data func (r *Runner) Build(tmpDir string) error { var outputDir string if r.config.Output == "" { outputDir = filepath.Join(r.config.RootDir, "build", r.config.Namespace) } else { outputDir = r.config.Output } if r.config.HasShas() { 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 := ioutil.ReadDir(preBuildDir) if err != nil { return fmt.Errorf("cannot list directory: %s - %w", preBuildDir, err) } if err := cleanDir(outputDir); err != nil { return fmt.Errorf("cannot clean dir: %s: %w", outputDir, err) } variables, err := r.config.prepareVariables(true) for _, file := range files { inFilePath := filepath.Join(preBuildDir, file.Name()) outFilePath := filepath.Join(outputDir, file.Name()) outFile, err := os.Create(outFilePath) if err != nil { return fmt.Errorf("cannot open: %s - %w", outFilePath, err) } defer func() { _ = outFile.Close() }() if err := hydrate(inFilePath, outFile, variables); err != nil { return fmt.Errorf("cannot hidrate: %s - %w", outFilePath, err) } } return nil } return r.DoBuild(tmpDir, outputDir) } func (r *Runner) DoBuild(tmpDir, outputDir string) error { // TODO: find command full path var yttCmd = "ytt" var helmCmd = "helm" var kubectlCmd = "kubectl" // create helm commands // create ytt chart commands cmds := make(map[string]*cmd.Cmd) for name, chart := range r.config.Spec.Charts { args, err := chart.BuildArgs(name, r.config.Namespace) if err != nil { return 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...) default: return 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 } // run commands or print them var compiled []string if r.config.DryRun { for _, cmd := range cmds { r.config.Logger.Info(). Str("command", cmd.Name). Str("args", strings.Join(cmd.Args, " ")). Msg("would run command") } } else { for name, cmd := range cmds { 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") // TODO: print error to stderr // Error must be pretty printed to end users /!\ fmt.Printf("\n%s\n\n", strings.Join(stdErr, "\n")) return fmt.Errorf("failed to run command: %w", err) } tmpFile, err := ioutil.TempFile(tmpDir, fmt.Sprintf("compiled-%s-*.yaml", name)) if err != nil { return 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 _, err := tmpFile.WriteString(strings.Join(stdOut, "\n")); err != nil { return fmt.Errorf("cannot write compiled file: %w", err) } compiled = append(compiled, tmpFile.Name()) } } // create ytt additional command args := r.config.BuildYttArgs(r.config.Spec.Ytt, compiled) yttExtraCmd := cmd.NewCmd(yttCmd, args...) if r.config.DryRun { r.config.Logger.Info(). Str("command", yttExtraCmd.Name). Str("args", strings.Join(yttExtraCmd.Args, " ")). Msg("would run command") return nil } stdOut, stdErr, err := RunCMD(yttExtraCmd) if err != nil { r.config.Logger.Err(err). Str("command", yttExtraCmd.Name). Str("args", strings.Join(yttExtraCmd.Args, " ")). Str("sdtout", strings.Join(stdOut, "\n")). Str("stderr", strings.Join(stdErr, "\n")). Msg("failed to run command") // Error message must be pretty printed to end users fmt.Printf("\n%s\n\n", strings.Join(stdErr, "\n")) return fmt.Errorf("failed to run command: %w", err) } tmpFile, err := ioutil.TempFile(tmpDir, "fully-compiled-") if err != nil { return fmt.Errorf("cannot create fully compiled file: %w", err) } defer func() { if err := tmpFile.Close(); err != nil { r.config.Logger.Err(err). Str("tmp file", tmpFile.Name()). Msg("failed to close temp file") } }() if _, err := tmpFile.WriteString(strings.Join(stdOut, "\n")); err != nil { return fmt.Errorf("cannot write full compiled file: %w", err) } if err := cleanDir(outputDir); err != nil { return fmt.Errorf("cannot clean dir: %s: %w", outputDir, err) } if _, err := YamlSplit(outputDir, tmpFile.Name()); err != nil { return fmt.Errorf("cannot split full compiled file: %w", err) } return nil } 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 } // YamlSplit takes a buildDier 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 { apiVersion, ok := resource["apiVersion"].(string) if !ok { return nil, fmt.Errorf("fail to type assert apiVersion from: %+v", resource) } kind, ok := resource["kind"].(string) if !ok { return nil, fmt.Errorf("kind missing from: %+v", resource) } metadata, ok := resource["metadata"].(map[interface{}]interface{}) if !ok { return nil, fmt.Errorf("fail to type assert metadata from: %+v", resource) } name, ok := metadata["name"].(string) if !ok { return nil, fmt.Errorf("fail to type assert metadata.name from: %+v", resource) } filename := fmt.Sprintf("%s.%s.%s.yaml", kind, strings.ReplaceAll(apiVersion, "/", "_"), name) fPath := filepath.Join(buildDir, filename) 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"), out...) 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 }