github.com/tilt-dev/tilt@v0.36.0/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 if name == "" { 248 // Use 'chart' as the release name, so that the release name is stable 249 // across Tiltfile loads. 250 // This looks like what helm does. 251 // https://github.com/helm/helm/blob/e672a42efae30d45ddd642a26557dcdbf5a9f5f0/pkg/action/install.go#L562 252 name = "chart" 253 } 254 255 var cmd = []string{"helm", "template", name, localPath} 256 if namespace != "" { 257 cmd = append(cmd, "--namespace", namespace) 258 } 259 260 if kubeVersion != "" { 261 cmd = append(cmd, "--kube-version", kubeVersion) 262 } 263 264 if !skip_crds && version == helmV3_1andAbove { 265 cmd = append(cmd, "--include-crds") 266 } 267 268 for _, valueFile := range valueFiles.Values { 269 cmd = append(cmd, "--values", valueFile) 270 err := tiltfile_io.RecordReadPath(thread, tiltfile_io.WatchFileOnly, starkit.AbsPath(thread, valueFile)) 271 if err != nil { 272 return nil, err 273 } 274 } 275 for _, setArg := range set.Values { 276 cmd = append(cmd, "--set", setArg) 277 } 278 279 stdout, err := s.execLocalCmd(thread, model.Cmd{Argv: cmd, Dir: starkit.AbsWorkingDir(thread)}, execCommandOptions{ 280 logOutput: false, 281 logCommand: true, 282 }) 283 if err != nil { 284 return nil, err 285 } 286 287 yaml := filterHelmTestYAML(stdout) 288 289 if version == helmV3_0 { 290 // Helm v3.0 has a bug where it doesn't include CRDs in the template output 291 // https://github.com/tilt-dev/tilt/issues/3605 292 crds, err := getHelmCRDs(localPath) 293 if err != nil { 294 return nil, err 295 } 296 yaml = strings.Join(append([]string{yaml}, crds...), "\n---\n") 297 } 298 299 if namespace != "" { 300 // helm template --namespace doesn't inject the namespace, nor provide 301 // YAML that defines the namespace, so we have to do both ourselves :\ 302 // https://github.com/helm/helm/issues/5465 303 parsed, err := k8s.ParseYAMLFromString(yaml) 304 if err != nil { 305 return nil, err 306 } 307 308 for i, e := range parsed { 309 parsed[i] = e.WithNamespace(e.NamespaceOrDefault(namespace)) 310 } 311 312 yaml, err = k8s.SerializeSpecYAML(parsed) 313 if err != nil { 314 return nil, err 315 } 316 } 317 318 return tiltfile_io.NewBlob(yaml, fmt.Sprintf("helm: %s", localPath)), nil 319 } 320 321 // NOTE(nick): This isn't perfect. For example, it doesn't handle chart deps 322 // properly. When possible, prefer Helm 3.1's --include-crds 323 func getHelmCRDs(path string) ([]string, error) { 324 crdPath := filepath.Join(path, "crds") 325 result := []string{} 326 err := filepath.Walk(crdPath, func(path string, info os.FileInfo, err error) error { 327 if err != nil { 328 return err 329 } 330 isYAML := info != nil && info.Mode().IsRegular() && hasYAMLExtension(path) 331 if !isYAML { 332 return nil 333 } 334 contents, err := os.ReadFile(path) 335 if err != nil { 336 if os.IsNotExist(err) { 337 return nil 338 } 339 return err 340 } 341 result = append(result, string(contents)) 342 return nil 343 }) 344 345 if err != nil && !os.IsNotExist(err) { 346 return nil, err 347 } 348 return result, nil 349 } 350 351 func hasYAMLExtension(fname string) bool { 352 ext := filepath.Ext(fname) 353 return strings.EqualFold(ext, ".yaml") || strings.EqualFold(ext, ".yml") 354 }