github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/tiltfile/files.go (about)

     1  package tiltfile
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"os/exec"
     9  	"path/filepath"
    10  	"strings"
    11  
    12  	"github.com/tilt-dev/tilt/internal/k8s"
    13  	"github.com/tilt-dev/tilt/internal/localexec"
    14  	tiltfile_io "github.com/tilt-dev/tilt/internal/tiltfile/io"
    15  	"github.com/tilt-dev/tilt/internal/tiltfile/starkit"
    16  	"github.com/tilt-dev/tilt/internal/tiltfile/value"
    17  	"github.com/tilt-dev/tilt/pkg/logger"
    18  	"github.com/tilt-dev/tilt/pkg/model"
    19  
    20  	"github.com/pkg/errors"
    21  	"go.starlark.net/starlark"
    22  
    23  	"github.com/tilt-dev/tilt/internal/kustomize"
    24  )
    25  
    26  const localLogPrefix = " → "
    27  
    28  type execCommandOptions struct {
    29  	// logOutput writes stdout and stderr to logs if true.
    30  	logOutput bool
    31  	// logCommand writes the command being executed to logs if true.
    32  	logCommand bool
    33  	// logCommandPrefix is a custom prefix before the command (default: "Running: ") used if logCommand is true.
    34  	logCommandPrefix string
    35  	// stdin, if non-nil, will be written to the command's stdin
    36  	stdin *string
    37  }
    38  
    39  func (s *tiltfileState) local(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
    40  	var commandValue, commandBatValue, commandDirValue starlark.Value
    41  	var commandEnv value.StringStringMap
    42  	var stdin value.Stringable
    43  	quiet := false
    44  	echoOff := false
    45  	err := s.unpackArgs(fn.Name(), args, kwargs,
    46  		"command", &commandValue,
    47  		"quiet?", &quiet,
    48  		"command_bat", &commandBatValue,
    49  		"echo_off", &echoOff,
    50  		"env", &commandEnv,
    51  		"dir?", &commandDirValue,
    52  		"stdin?", &stdin,
    53  	)
    54  	if err != nil {
    55  		return nil, err
    56  	}
    57  
    58  	cmd, err := value.ValueGroupToCmdHelper(thread, commandValue, commandBatValue, commandDirValue, commandEnv)
    59  	if err != nil {
    60  		return nil, err
    61  	}
    62  
    63  	execOptions := execCommandOptions{
    64  		logOutput:        !quiet,
    65  		logCommand:       !echoOff,
    66  		logCommandPrefix: "local:",
    67  	}
    68  	if stdin.IsSet {
    69  		s := stdin.Value
    70  		execOptions.stdin = &s
    71  	}
    72  	out, err := s.execLocalCmd(thread, cmd, execOptions)
    73  	if err != nil {
    74  		return nil, err
    75  	}
    76  
    77  	return tiltfile_io.NewBlob(out, fmt.Sprintf("local: %s", cmd)), nil
    78  }
    79  
    80  func (s *tiltfileState) execLocalCmd(t *starlark.Thread, cmd model.Cmd, options execCommandOptions) (string, error) {
    81  	var stdoutBuf, stderrBuf bytes.Buffer
    82  	ctx, err := starkit.ContextFromThread(t)
    83  	if err != nil {
    84  		return "", err
    85  	}
    86  
    87  	if options.logCommand {
    88  		prefix := options.logCommandPrefix
    89  		if prefix == "" {
    90  			prefix = "Running:"
    91  		}
    92  		s.logger.Infof("%s %s", prefix, cmd)
    93  	}
    94  
    95  	var runIO localexec.RunIO
    96  	if options.logOutput {
    97  		logOutput := logger.NewMutexWriter(logger.NewPrefixedLogger(localLogPrefix, s.logger).Writer(logger.InfoLvl))
    98  		runIO.Stdout = io.MultiWriter(&stdoutBuf, logOutput)
    99  		runIO.Stderr = io.MultiWriter(&stderrBuf, logOutput)
   100  	} else {
   101  		runIO.Stdout = &stdoutBuf
   102  		runIO.Stderr = &stderrBuf
   103  	}
   104  
   105  	if options.stdin != nil {
   106  		runIO.Stdin = strings.NewReader(*options.stdin)
   107  	}
   108  
   109  	// TODO(nick): Should this also inject any docker.Env overrides?
   110  	exitCode, err := s.execer.Run(ctx, cmd, runIO)
   111  	if err != nil || exitCode != 0 {
   112  		var errMessage strings.Builder
   113  		errMessage.WriteString(fmt.Sprintf("command %q failed.", cmd))
   114  		if err != nil {
   115  			errMessage.WriteString(fmt.Sprintf("\nerror: %v", err))
   116  		} else {
   117  			errMessage.WriteString(fmt.Sprintf("\nerror: exit status %d", exitCode))
   118  		}
   119  
   120  		if !options.logOutput {
   121  			// if we already logged the output, don't include it in the error message to prevent it from
   122  			// getting output 2x
   123  
   124  			stdout, stderr := stdoutBuf.String(), stderrBuf.String()
   125  			fmt.Fprintf(&errMessage, "\nstdout:\n%v\nstderr:\n%v\n", stdout, stderr)
   126  		}
   127  
   128  		return "", errors.New(errMessage.String())
   129  	}
   130  
   131  	// only show that there was no output if the command was echoed AND we wanted output logged
   132  	// otherwise, it's confusing to get "[no output]" without context of _what_ didn't have output
   133  	if options.logCommand && options.logOutput && stdoutBuf.Len() == 0 && stderrBuf.Len() == 0 {
   134  		s.logger.Infof("%s[no output]", localLogPrefix)
   135  	}
   136  
   137  	return stdoutBuf.String(), nil
   138  }
   139  
   140  func (s *tiltfileState) kustomize(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
   141  	path, kustomizeBin := value.NewLocalPathUnpacker(thread), value.NewLocalPathUnpacker(thread)
   142  	flags := value.StringList{}
   143  	err := s.unpackArgs(fn.Name(), args, kwargs, "paths", &path, "kustomize_bin?", &kustomizeBin, "flags?", &flags)
   144  	if err != nil {
   145  		return nil, err
   146  	}
   147  
   148  	kustomizeArgs := []string{"kustomize", "build"}
   149  
   150  	if kustomizeBin.Value != "" {
   151  		kustomizeArgs[0] = kustomizeBin.Value
   152  	}
   153  
   154  	_, err = exec.LookPath(kustomizeArgs[0])
   155  	if err != nil {
   156  		if kustomizeBin.Value != "" {
   157  			return nil, err
   158  		}
   159  		s.logger.Infof("Falling back to `kubectl kustomize` since `%s` was not found in PATH", kustomizeArgs[0])
   160  		kustomizeArgs = []string{"kubectl", "kustomize"}
   161  	}
   162  
   163  	// NOTE(nick): There's a bug in kustomize where it doesn't properly
   164  	// handle absolute paths. Convert to relative paths instead:
   165  	// https://github.com/kubernetes-sigs/kustomize/issues/2789
   166  	relKustomizePath, err := filepath.Rel(starkit.AbsWorkingDir(thread), path.Value)
   167  	if err != nil {
   168  		return nil, err
   169  	}
   170  
   171  	cmd := model.Cmd{Argv: append(append(kustomizeArgs, flags...), relKustomizePath), Dir: starkit.AbsWorkingDir(thread)}
   172  	yaml, err := s.execLocalCmd(thread, cmd, execCommandOptions{
   173  		logOutput:  false,
   174  		logCommand: true,
   175  	})
   176  	if err != nil {
   177  		return nil, err
   178  	}
   179  	deps, err := kustomize.Deps(path.Value)
   180  	if err != nil {
   181  		return nil, fmt.Errorf("resolving deps: %v", err)
   182  	}
   183  	for _, d := range deps {
   184  		err := tiltfile_io.RecordReadPath(thread, tiltfile_io.WatchRecursive, d)
   185  		if err != nil {
   186  			return nil, err
   187  		}
   188  	}
   189  
   190  	return tiltfile_io.NewBlob(yaml, fmt.Sprintf("kustomize: %s", path.Value)), nil
   191  }
   192  
   193  func (s *tiltfileState) helm(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
   194  	path := value.NewLocalPathUnpacker(thread)
   195  	var name string
   196  	var namespace string
   197  	var valueFiles value.StringOrStringList
   198  	var set value.StringOrStringList
   199  	var kubeVersion string
   200  	var skip_crds bool
   201  
   202  	err := s.unpackArgs(fn.Name(), args, kwargs,
   203  		"paths", &path,
   204  		"name?", &name,
   205  		"namespace?", &namespace,
   206  		"values?", &valueFiles,
   207  		"set?", &set,
   208  		"kube_version?", &kubeVersion,
   209  		"skip_crds?", &skip_crds,
   210  	)
   211  	if err != nil {
   212  		return nil, err
   213  	}
   214  
   215  	localPath := path.Value
   216  	info, err := os.Stat(localPath)
   217  	if err != nil {
   218  		if os.IsNotExist(err) {
   219  			return nil, fmt.Errorf("Could not read Helm chart directory %q: does not exist", localPath)
   220  		}
   221  		return nil, fmt.Errorf("Could not read Helm chart directory %q: %v", localPath, err)
   222  	} else if !info.IsDir() {
   223  		return nil, fmt.Errorf("helm() may only be called on directories with Chart.yaml: %q", localPath)
   224  	}
   225  
   226  	err = tiltfile_io.RecordReadPath(thread, tiltfile_io.WatchRecursive, localPath)
   227  	if err != nil {
   228  		return nil, err
   229  	}
   230  
   231  	deps, err := localSubchartDependenciesFromPath(localPath)
   232  	if err != nil {
   233  		return nil, err
   234  	}
   235  	for _, d := range deps {
   236  		err = tiltfile_io.RecordReadPath(thread, tiltfile_io.WatchRecursive, starkit.AbsPath(thread, d))
   237  		if err != nil {
   238  			return nil, err
   239  		}
   240  	}
   241  
   242  	version, err := getHelmVersion()
   243  	if err != nil {
   244  		return nil, err
   245  	}
   246  
   247  	var cmd []string
   248  
   249  	if name == "" {
   250  		// Use 'chart' as the release name, so that the release name is stable
   251  		// across Tiltfile loads.
   252  		// This looks like what helm does.
   253  		// https://github.com/helm/helm/blob/e672a42efae30d45ddd642a26557dcdbf5a9f5f0/pkg/action/install.go#L562
   254  		name = "chart"
   255  	}
   256  
   257  	if version == helmV3_0 || version == helmV3_1andAbove {
   258  		cmd = []string{"helm", "template", name, localPath}
   259  	} else {
   260  		cmd = []string{"helm", "template", localPath, "--name", name}
   261  	}
   262  
   263  	if namespace != "" {
   264  		cmd = append(cmd, "--namespace", namespace)
   265  	}
   266  
   267  	if kubeVersion != "" {
   268  		cmd = append(cmd, "--kube-version", kubeVersion)
   269  	}
   270  
   271  	if !skip_crds && version == helmV3_1andAbove {
   272  		cmd = append(cmd, "--include-crds")
   273  	}
   274  
   275  	for _, valueFile := range valueFiles.Values {
   276  		cmd = append(cmd, "--values", valueFile)
   277  		err := tiltfile_io.RecordReadPath(thread, tiltfile_io.WatchFileOnly, starkit.AbsPath(thread, valueFile))
   278  		if err != nil {
   279  			return nil, err
   280  		}
   281  	}
   282  	for _, setArg := range set.Values {
   283  		cmd = append(cmd, "--set", setArg)
   284  	}
   285  
   286  	stdout, err := s.execLocalCmd(thread, model.Cmd{Argv: cmd, Dir: starkit.AbsWorkingDir(thread)}, execCommandOptions{
   287  		logOutput:  false,
   288  		logCommand: true,
   289  	})
   290  	if err != nil {
   291  		return nil, err
   292  	}
   293  
   294  	yaml := filterHelmTestYAML(stdout)
   295  
   296  	if version == helmV3_0 {
   297  		// Helm v3.0 has a bug where it doesn't include CRDs in the template output
   298  		// https://github.com/tilt-dev/tilt/issues/3605
   299  		crds, err := getHelmCRDs(localPath)
   300  		if err != nil {
   301  			return nil, err
   302  		}
   303  		yaml = strings.Join(append([]string{yaml}, crds...), "\n---\n")
   304  	}
   305  
   306  	if namespace != "" {
   307  		// helm template --namespace doesn't inject the namespace, nor provide
   308  		// YAML that defines the namespace, so we have to do both ourselves :\
   309  		// https://github.com/helm/helm/issues/5465
   310  		parsed, err := k8s.ParseYAMLFromString(yaml)
   311  		if err != nil {
   312  			return nil, err
   313  		}
   314  
   315  		for i, e := range parsed {
   316  			parsed[i] = e.WithNamespace(e.NamespaceOrDefault(namespace))
   317  		}
   318  
   319  		yaml, err = k8s.SerializeSpecYAML(parsed)
   320  		if err != nil {
   321  			return nil, err
   322  		}
   323  	}
   324  
   325  	return tiltfile_io.NewBlob(yaml, fmt.Sprintf("helm: %s", localPath)), nil
   326  }
   327  
   328  // NOTE(nick): This isn't perfect. For example, it doesn't handle chart deps
   329  // properly. When possible, prefer Helm 3.1's --include-crds
   330  func getHelmCRDs(path string) ([]string, error) {
   331  	crdPath := filepath.Join(path, "crds")
   332  	result := []string{}
   333  	err := filepath.Walk(crdPath, func(path string, info os.FileInfo, err error) error {
   334  		if err != nil {
   335  			return err
   336  		}
   337  		isYAML := info != nil && info.Mode().IsRegular() && hasYAMLExtension(path)
   338  		if !isYAML {
   339  			return nil
   340  		}
   341  		contents, err := os.ReadFile(path)
   342  		if err != nil {
   343  			if os.IsNotExist(err) {
   344  				return nil
   345  			}
   346  			return err
   347  		}
   348  		result = append(result, string(contents))
   349  		return nil
   350  	})
   351  
   352  	if err != nil && !os.IsNotExist(err) {
   353  		return nil, err
   354  	}
   355  	return result, nil
   356  }
   357  
   358  func hasYAMLExtension(fname string) bool {
   359  	ext := filepath.Ext(fname)
   360  	return strings.EqualFold(ext, ".yaml") || strings.EqualFold(ext, ".yml")
   361  }