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 }