github.com/argoproj/argo-cd/v3@v3.2.1/util/kustomize/kustomize.go (about) 1 package kustomize 2 3 import ( 4 "errors" 5 "fmt" 6 "net/url" 7 "os" 8 "os/exec" 9 "path/filepath" 10 "regexp" 11 "sort" 12 "strings" 13 "sync" 14 15 "github.com/Masterminds/semver/v3" 16 "sigs.k8s.io/yaml" 17 18 "github.com/argoproj/argo-cd/v3/util/io" 19 20 "github.com/argoproj/gitops-engine/pkg/utils/kube" 21 log "github.com/sirupsen/logrus" 22 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 23 24 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" 25 certutil "github.com/argoproj/argo-cd/v3/util/cert" 26 executil "github.com/argoproj/argo-cd/v3/util/exec" 27 "github.com/argoproj/argo-cd/v3/util/git" 28 "github.com/argoproj/argo-cd/v3/util/proxy" 29 ) 30 31 // Image represents a Docker image in the format NAME[:TAG]. 32 type Image = string 33 34 type BuildOpts struct { 35 KubeVersion string 36 APIVersions []string 37 } 38 39 // Kustomize provides wrapper functionality around the `kustomize` command. 40 type Kustomize interface { 41 // Build returns a list of unstructured objects from a `kustomize build` command and extract supported parameters 42 Build(opts *v1alpha1.ApplicationSourceKustomize, kustomizeOptions *v1alpha1.KustomizeOptions, envVars *v1alpha1.Env, buildOpts *BuildOpts) ([]*unstructured.Unstructured, []Image, []string, error) 43 } 44 45 // NewKustomizeApp create a new wrapper to run commands on the `kustomize` command-line tool. 46 func NewKustomizeApp(repoRoot string, path string, creds git.Creds, fromRepo string, binaryPath string, proxy string, noProxy string) Kustomize { 47 return &kustomize{ 48 repoRoot: repoRoot, 49 path: path, 50 creds: creds, 51 repo: fromRepo, 52 binaryPath: binaryPath, 53 proxy: proxy, 54 noProxy: noProxy, 55 } 56 } 57 58 type kustomize struct { 59 // path to the Git repository root 60 repoRoot string 61 // path inside the checked out tree 62 path string 63 // creds structure 64 creds git.Creds 65 // the Git repository URL where we checked out 66 repo string 67 // optional kustomize binary path 68 binaryPath string 69 // HTTP/HTTPS proxy used to access repository 70 proxy string 71 // NoProxy specifies a list of targets where the proxy isn't used, applies only in cases where the proxy is applied 72 noProxy string 73 } 74 75 var KustomizationNames = []string{"kustomization.yaml", "kustomization.yml", "Kustomization"} 76 77 // IsKustomization checks if the given file name matches any known kustomization file names. 78 func IsKustomization(path string) bool { 79 for _, kustomization := range KustomizationNames { 80 if path == kustomization { 81 return true 82 } 83 } 84 return false 85 } 86 87 // findKustomizeFile looks for any known kustomization file in the path 88 func findKustomizeFile(dir string) string { 89 for _, file := range KustomizationNames { 90 path := filepath.Join(dir, file) 91 if _, err := os.Stat(path); err == nil { 92 return file 93 } 94 } 95 96 return "" 97 } 98 99 func (k *kustomize) getBinaryPath() string { 100 if k.binaryPath != "" { 101 return k.binaryPath 102 } 103 return "kustomize" 104 } 105 106 // kustomize v3.8.5 patch release introduced a breaking change in "edit add <label/annotation>" commands: 107 // https://github.com/kubernetes-sigs/kustomize/commit/b214fa7d5aa51d7c2ae306ec15115bf1c044fed8#diff-0328c59bcd29799e365ff0647653b886f17c8853df008cd54e7981db882c1b36 108 func mapToEditAddArgs(val map[string]string) []string { 109 var args []string 110 if getSemverSafe(&kustomize{}).LessThan(semver.MustParse("v3.8.5")) { 111 arg := "" 112 for labelName, labelValue := range val { 113 if arg != "" { 114 arg += "," 115 } 116 arg += fmt.Sprintf("%s:%s", labelName, labelValue) 117 } 118 args = append(args, arg) 119 } else { 120 for labelName, labelValue := range val { 121 args = append(args, fmt.Sprintf("%s:%s", labelName, labelValue)) 122 } 123 } 124 return args 125 } 126 127 func (k *kustomize) Build(opts *v1alpha1.ApplicationSourceKustomize, kustomizeOptions *v1alpha1.KustomizeOptions, envVars *v1alpha1.Env, buildOpts *BuildOpts) ([]*unstructured.Unstructured, []Image, []string, error) { 128 // commands stores all the commands that were run as part of this build. 129 var commands []string 130 131 env := os.Environ() 132 if envVars != nil { 133 env = append(env, envVars.Environ()...) 134 } 135 136 closer, environ, err := k.creds.Environ() 137 if err != nil { 138 return nil, nil, nil, err 139 } 140 defer func() { _ = closer.Close() }() 141 142 // If we were passed a HTTPS URL, make sure that we also check whether there 143 // is a custom CA bundle configured for connecting to the server. 144 if k.repo != "" && git.IsHTTPSURL(k.repo) { 145 parsedURL, err := url.Parse(k.repo) 146 if err != nil { 147 log.Warnf("Could not parse URL %s: %v", k.repo, err) 148 } else { 149 caPath, err := certutil.GetCertBundlePathForRepository(parsedURL.Host) 150 switch { 151 case err != nil: 152 // Some error while getting CA bundle 153 log.Warnf("Could not get CA bundle path for %s: %v", parsedURL.Host, err) 154 case caPath == "": 155 // No cert configured 156 log.Debugf("No caCert found for repo %s", parsedURL.Host) 157 default: 158 // Make Git use CA bundle 159 environ = append(environ, "GIT_SSL_CAINFO="+caPath) 160 } 161 } 162 } 163 164 env = append(env, environ...) 165 166 if opts != nil { 167 if opts.NamePrefix != "" { 168 cmd := exec.Command(k.getBinaryPath(), "edit", "set", "nameprefix", "--", opts.NamePrefix) 169 cmd.Dir = k.path 170 commands = append(commands, executil.GetCommandArgsToLog(cmd)) 171 _, err := executil.Run(cmd) 172 if err != nil { 173 return nil, nil, nil, err 174 } 175 } 176 if opts.NameSuffix != "" { 177 cmd := exec.Command(k.getBinaryPath(), "edit", "set", "namesuffix", "--", opts.NameSuffix) 178 cmd.Dir = k.path 179 commands = append(commands, executil.GetCommandArgsToLog(cmd)) 180 _, err := executil.Run(cmd) 181 if err != nil { 182 return nil, nil, nil, err 183 } 184 } 185 if len(opts.Images) > 0 { 186 // set image postgres=eu.gcr.io/my-project/postgres:latest my-app=my-registry/my-app@sha256:24a0c4b4a4c0eb97a1aabb8e29f18e917d05abfe1b7a7c07857230879ce7d3d3 187 // set image node:8.15.0 mysql=mariadb alpine@sha256:24a0c4b4a4c0eb97a1aabb8e29f18e917d05abfe1b7a7c07857230879ce7d3d3 188 args := []string{"edit", "set", "image"} 189 for _, image := range opts.Images { 190 // this allows using ${ARGOCD_APP_REVISION} 191 envSubstitutedImage := envVars.Envsubst(string(image)) 192 args = append(args, envSubstitutedImage) 193 } 194 cmd := exec.Command(k.getBinaryPath(), args...) 195 cmd.Dir = k.path 196 commands = append(commands, executil.GetCommandArgsToLog(cmd)) 197 _, err := executil.Run(cmd) 198 if err != nil { 199 return nil, nil, nil, err 200 } 201 } 202 203 if len(opts.Replicas) > 0 { 204 // set replicas my-development=2 my-statefulset=4 205 args := []string{"edit", "set", "replicas"} 206 for _, replica := range opts.Replicas { 207 count, err := replica.GetIntCount() 208 if err != nil { 209 return nil, nil, nil, err 210 } 211 arg := fmt.Sprintf("%s=%d", replica.Name, count) 212 args = append(args, arg) 213 } 214 215 cmd := exec.Command(k.getBinaryPath(), args...) 216 cmd.Dir = k.path 217 commands = append(commands, executil.GetCommandArgsToLog(cmd)) 218 _, err := executil.Run(cmd) 219 if err != nil { 220 return nil, nil, nil, err 221 } 222 } 223 224 if len(opts.CommonLabels) > 0 { 225 // edit add label foo:bar 226 args := []string{"edit", "add", "label"} 227 if opts.ForceCommonLabels { 228 args = append(args, "--force") 229 } 230 if opts.LabelWithoutSelector { 231 args = append(args, "--without-selector") 232 } 233 if opts.LabelIncludeTemplates { 234 args = append(args, "--include-templates") 235 } 236 commonLabels := map[string]string{} 237 for name, value := range opts.CommonLabels { 238 commonLabels[name] = envVars.Envsubst(value) 239 } 240 cmd := exec.Command(k.getBinaryPath(), append(args, mapToEditAddArgs(commonLabels)...)...) 241 cmd.Dir = k.path 242 commands = append(commands, executil.GetCommandArgsToLog(cmd)) 243 _, err := executil.Run(cmd) 244 if err != nil { 245 return nil, nil, nil, err 246 } 247 } 248 249 if len(opts.CommonAnnotations) > 0 { 250 // edit add annotation foo:bar 251 args := []string{"edit", "add", "annotation"} 252 if opts.ForceCommonAnnotations { 253 args = append(args, "--force") 254 } 255 var commonAnnotations map[string]string 256 if opts.CommonAnnotationsEnvsubst { 257 commonAnnotations = map[string]string{} 258 for name, value := range opts.CommonAnnotations { 259 commonAnnotations[name] = envVars.Envsubst(value) 260 } 261 } else { 262 commonAnnotations = opts.CommonAnnotations 263 } 264 cmd := exec.Command(k.getBinaryPath(), append(args, mapToEditAddArgs(commonAnnotations)...)...) 265 cmd.Dir = k.path 266 commands = append(commands, executil.GetCommandArgsToLog(cmd)) 267 _, err := executil.Run(cmd) 268 if err != nil { 269 return nil, nil, nil, err 270 } 271 } 272 273 if opts.Namespace != "" { 274 cmd := exec.Command(k.getBinaryPath(), "edit", "set", "namespace", "--", opts.Namespace) 275 cmd.Dir = k.path 276 commands = append(commands, executil.GetCommandArgsToLog(cmd)) 277 _, err := executil.Run(cmd) 278 if err != nil { 279 return nil, nil, nil, err 280 } 281 } 282 283 if len(opts.Patches) > 0 { 284 kustFile := findKustomizeFile(k.path) 285 // If the kustomization file is not found, return early. 286 // There is no point reading the kustomization path if it doesn't exist. 287 if kustFile == "" { 288 return nil, nil, nil, errors.New("kustomization file not found in the path") 289 } 290 kustomizationPath := filepath.Join(k.path, kustFile) 291 b, err := os.ReadFile(kustomizationPath) 292 if err != nil { 293 return nil, nil, nil, fmt.Errorf("failed to load kustomization.yaml: %w", err) 294 } 295 var kustomization any 296 err = yaml.Unmarshal(b, &kustomization) 297 if err != nil { 298 return nil, nil, nil, fmt.Errorf("failed to unmarshal kustomization.yaml: %w", err) 299 } 300 kMap, ok := kustomization.(map[string]any) 301 if !ok { 302 return nil, nil, nil, fmt.Errorf("expected kustomization.yaml to be type map[string]any, but got %T", kMap) 303 } 304 patches, ok := kMap["patches"] 305 if ok { 306 // The kustomization.yaml already had a patches field, so we need to append to it. 307 patchesList, ok := patches.([]any) 308 if !ok { 309 return nil, nil, nil, fmt.Errorf("expected 'patches' field in kustomization.yaml to be []any, but got %T", patches) 310 } 311 // Since the patches from the Application manifest are typed, we need to convert them to a type which 312 // can be appended to the existing list. 313 untypedPatches := make([]any, len(opts.Patches)) 314 for i := range opts.Patches { 315 untypedPatches[i] = opts.Patches[i] 316 } 317 patchesList = append(patchesList, untypedPatches...) 318 // Update the kustomization.yaml with the appended patches list. 319 kMap["patches"] = patchesList 320 } else { 321 kMap["patches"] = opts.Patches 322 } 323 updatedKustomization, err := yaml.Marshal(kMap) 324 if err != nil { 325 return nil, nil, nil, fmt.Errorf("failed to marshal kustomization.yaml after adding patches: %w", err) 326 } 327 kustomizationFileInfo, err := os.Stat(kustomizationPath) 328 if err != nil { 329 return nil, nil, nil, fmt.Errorf("failed to stat kustomization.yaml: %w", err) 330 } 331 err = os.WriteFile(kustomizationPath, updatedKustomization, kustomizationFileInfo.Mode()) 332 if err != nil { 333 return nil, nil, nil, fmt.Errorf("failed to write kustomization.yaml with updated 'patches' field: %w", err) 334 } 335 commands = append(commands, "# kustomization.yaml updated with patches. There is no `kustomize edit` command for adding patches. In order to generate the manifests in your local environment, you will need to copy the patches into kustomization.yaml manually.") 336 } 337 338 if len(opts.Components) > 0 { 339 // components only supported in kustomize >= v3.7.0 340 // https://github.com/kubernetes-sigs/kustomize/blob/master/examples/components.md 341 if getSemverSafe(k).LessThan(semver.MustParse("v3.7.0")) { 342 return nil, nil, nil, errors.New("kustomize components require kustomize v3.7.0 and above") 343 } 344 345 // add components 346 foundComponents := opts.Components 347 if opts.IgnoreMissingComponents { 348 foundComponents = make([]string, 0) 349 root, err := os.OpenRoot(k.repoRoot) 350 defer io.Close(root) 351 if err != nil { 352 return nil, nil, nil, fmt.Errorf("failed to open the repo folder: %w", err) 353 } 354 355 for _, c := range opts.Components { 356 resolvedPath, err := filepath.Rel(k.repoRoot, filepath.Join(k.path, c)) 357 if err != nil { 358 return nil, nil, nil, fmt.Errorf("kustomize components path failed: %w", err) 359 } 360 _, err = root.Stat(resolvedPath) 361 if err != nil { 362 log.Debugf("%s component directory does not exist", resolvedPath) 363 continue 364 } 365 foundComponents = append(foundComponents, c) 366 } 367 } 368 369 if len(foundComponents) > 0 { 370 args := []string{"edit", "add", "component"} 371 args = append(args, foundComponents...) 372 cmd := exec.Command(k.getBinaryPath(), args...) 373 cmd.Dir = k.path 374 cmd.Env = env 375 commands = append(commands, executil.GetCommandArgsToLog(cmd)) 376 _, err := executil.Run(cmd) 377 if err != nil { 378 return nil, nil, nil, err 379 } 380 } 381 } 382 } 383 384 var cmd *exec.Cmd 385 if kustomizeOptions != nil && kustomizeOptions.BuildOptions != "" { 386 params := parseKustomizeBuildOptions(k, kustomizeOptions.BuildOptions, buildOpts) 387 cmd = exec.Command(k.getBinaryPath(), params...) 388 } else { 389 cmd = exec.Command(k.getBinaryPath(), "build", k.path) 390 } 391 cmd.Env = env 392 cmd.Env = proxy.UpsertEnv(cmd, k.proxy, k.noProxy) 393 cmd.Dir = k.repoRoot 394 commands = append(commands, executil.GetCommandArgsToLog(cmd)) 395 out, err := executil.Run(cmd) 396 if err != nil { 397 return nil, nil, nil, err 398 } 399 400 objs, err := kube.SplitYAML([]byte(out)) 401 if err != nil { 402 return nil, nil, nil, err 403 } 404 405 redactedCommands := make([]string, len(commands)) 406 for i, c := range commands { 407 redactedCommands[i] = strings.ReplaceAll(c, k.repoRoot, ".") 408 } 409 410 return objs, getImageParameters(objs), redactedCommands, nil 411 } 412 413 func parseKustomizeBuildOptions(k *kustomize, buildOptions string, buildOpts *BuildOpts) []string { 414 buildOptsParams := append([]string{"build", k.path}, strings.Fields(buildOptions)...) 415 416 if buildOpts != nil && !getSemverSafe(k).LessThan(semver.MustParse("v5.3.0")) && isHelmEnabled(buildOptions) { 417 if buildOpts.KubeVersion != "" { 418 buildOptsParams = append(buildOptsParams, "--helm-kube-version", buildOpts.KubeVersion) 419 } 420 for _, v := range buildOpts.APIVersions { 421 buildOptsParams = append(buildOptsParams, "--helm-api-versions", v) 422 } 423 } 424 425 return buildOptsParams 426 } 427 428 func isHelmEnabled(buildOptions string) bool { 429 return strings.Contains(buildOptions, "--enable-helm") 430 } 431 432 // semver/v3 doesn't export the regexp anymore, so shamelessly copied it over to 433 // here. 434 // https://github.com/Masterminds/semver/blob/49c09bfed6adcffa16482ddc5e5588cffff9883a/version.go#L42 435 const semVerRegex string = `v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?` + 436 `(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` + 437 `(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` 438 439 var ( 440 unknownVersion = semver.MustParse("v99.99.99") 441 semverRegex = regexp.MustCompile(semVerRegex) 442 semVer *semver.Version 443 semVerLock sync.Mutex 444 ) 445 446 // getSemver returns parsed kustomize version 447 func getSemver(k *kustomize) (*semver.Version, error) { 448 verStr, err := versionWithBinaryPath(k) 449 if err != nil { 450 return nil, err 451 } 452 453 semverMatches := semverRegex.FindStringSubmatch(verStr) 454 if len(semverMatches) == 0 { 455 return nil, fmt.Errorf("expected string that includes semver formatted version but got: '%s'", verStr) 456 } 457 458 return semver.NewVersion(semverMatches[0]) 459 } 460 461 // getSemverSafe returns parsed kustomize version; 462 // if version cannot be parsed assumes that "kustomize version" output format changed again 463 // and fallback to latest ( v99.99.99 ) 464 func getSemverSafe(k *kustomize) *semver.Version { 465 if semVer == nil { 466 semVerLock.Lock() 467 defer semVerLock.Unlock() 468 469 if ver, err := getSemver(k); err != nil { 470 semVer = unknownVersion 471 log.Warnf("Failed to parse kustomize version: %v", err) 472 } else { 473 semVer = ver 474 } 475 } 476 return semVer 477 } 478 479 func Version() (string, error) { 480 return versionWithBinaryPath(&kustomize{}) 481 } 482 483 func versionWithBinaryPath(k *kustomize) (string, error) { 484 executable := k.getBinaryPath() 485 cmd := exec.Command(executable, "version", "--short") 486 // example version output: 487 // short: "{kustomize/v3.8.1 2020-07-16T00:58:46Z }" 488 version, err := executil.Run(cmd) 489 if err != nil { 490 return "", fmt.Errorf("could not get kustomize version: %w", err) 491 } 492 version = strings.TrimSpace(version) 493 // trim the curly braces 494 version = strings.TrimPrefix(version, "{") 495 version = strings.TrimSuffix(version, "}") 496 version = strings.TrimSpace(version) 497 498 // remove double space in middle 499 version = strings.ReplaceAll(version, " ", " ") 500 501 // remove extra 'kustomize/' before version 502 version = strings.TrimPrefix(version, "kustomize/") 503 return version, nil 504 } 505 506 func getImageParameters(objs []*unstructured.Unstructured) []Image { 507 var images []Image 508 for _, obj := range objs { 509 images = append(images, getImages(obj.Object)...) 510 } 511 sort.Strings(images) 512 return images 513 } 514 515 func getImages(object map[string]any) []Image { 516 var images []Image 517 for k, v := range object { 518 switch v := v.(type) { 519 case []any: 520 if k == "containers" || k == "initContainers" { 521 for _, obj := range v { 522 if mapObj, isMapObj := obj.(map[string]any); isMapObj { 523 if image, hasImage := mapObj["image"]; hasImage { 524 images = append(images, fmt.Sprintf("%s", image)) 525 } 526 } 527 } 528 } else { 529 for i := range v { 530 if mapObj, isMapObj := v[i].(map[string]any); isMapObj { 531 images = append(images, getImages(mapObj)...) 532 } 533 } 534 } 535 case map[string]any: 536 images = append(images, getImages(v)...) 537 } 538 } 539 return images 540 }