diff --git a/Makefile b/Makefile index 82bcc1acb20..fbc4f09120e 100644 --- a/Makefile +++ b/Makefile @@ -135,6 +135,18 @@ fmt: ## Run go fmt against code. vet: ## Run go vet against code. go vet ./... +.PHONY: update-dependencies +update-dependencies: ## Update Go dependencies and regenerate CRDs, manifests and bundle. + $(MAKE) -C argocd-operator update-dependencies + +.PHONY: update-dependencies-argocd +update-dependencies-argocd: ## Update only the Argo CD dependency. + $(MAKE) -C argocd-operator update-dependencies-argocd + +.PHONY: update-dependencies-gitops-promoter +update-dependencies-gitops-promoter: ## Update only the GitOps Promoter dependency. + $(MAKE) -C argocd-operator update-dependencies-gitops-promoter + .PHONY: test-all test-all: manifests generate fmt vet ## Run all tests. go test -timeout 1h ./... -coverprofile cover.out diff --git a/argocd-operator/hack/update-dependencies-script/README.md b/argocd-operator/hack/update-dependencies-script/README.md index 4924c762a64..556e5599d5a 100644 --- a/argocd-operator/hack/update-dependencies-script/README.md +++ b/argocd-operator/hack/update-dependencies-script/README.md @@ -16,4 +16,13 @@ Then run the script: make update-dependencies ``` -See `hack/update-dependencies-script/main.go` for list of dependencies that are updated. +See each dependency subdirectory's `main.go` for the list of files it updates. + +## Environment overrides + +- `GITOPS_PROMOTER_VERSION`: GitOps Promoter release from which CRDs are copied. It must match + the version pinned in `argocd-operator/go.mod`; the updater fails otherwise, since the copied + CRDs have to match the vendored controller. +- `REDIS_MIRROR_IMAGE`: image repository used to resolve the Redis digest. Defaults to + `quay.io/akuity/redis`. If the digest cannot be resolved (for example, the mirror does not have + the tag yet), the Redis digests are left unchanged and the rest of the update continues. diff --git a/argocd-operator/hack/update-dependencies-script/argocd/main.go b/argocd-operator/hack/update-dependencies-script/argocd/main.go index d6ab6aebc86..994984a604a 100644 --- a/argocd-operator/hack/update-dependencies-script/argocd/main.go +++ b/argocd-operator/hack/update-dependencies-script/argocd/main.go @@ -12,7 +12,19 @@ import ( "github.com/argoproj-labs/argocd-operator/dependency-upgrade/utils" ) -const ArgoCDGitHubRepoURL = "https://github.com/argoproj/argo-cd" +const ( + ArgoCDGitHubRepoURL = "https://github.com/argoproj/argo-cd" + + // Argo CD's install manifests reference images on public.ecr.aws, but the operator + // deploys mirrored images from quay.io (see common/defaults.go). Resolve the digest + // against the mirror so we never write a public.ecr.aws digest for a quay.io image. + // This constant should be consistent with ArgoCDDefaultRedisImage in common/defaults.go. + redisMirrorImageURL = "quay.io/akuity/redis" + + // redisMirrorImageEnvVar optionally overrides redisMirrorImageURL, for example when the + // default mirror has not picked up an upstream redis release yet. + redisMirrorImageEnvVar = "REDIS_MIRROR_IMAGE" +) func main() { wd, err := os.Getwd() @@ -27,6 +39,10 @@ func main() { return } + // argocd-operator is a Go submodule of the gitops-operator repository. The operator + // manifests (config/, bundle/) are maintained by the parent module. + gitopsOperatorRoot := filepath.Dir(argocdOperatorRoot) + // Version variable is in the form of 'v3.1.1', note the 'v' prefix targetArgoCDVersion := readTargetVersionFromMakefile(argocdOperatorRoot) @@ -49,17 +65,20 @@ func main() { return } + // Sanity test that the parent gitops-operator module (which owns config/ and bundle/) exists + if rootGoModExists, err := utils.FileExists(filepath.Join(gitopsOperatorRoot, "go.mod")); err != nil || !rootGoModExists { + utils.ExitWithError(fmt.Errorf("gitops-operator go.mod not found in '%s': %v", gitopsOperatorRoot, err)) + return + } + // update 'common/defaults.go': container images for dex, redis, redis HA, argo-cd, etc - argocdContainerInfo, dexContainerInfo := upgradeCommonDefaultsGo(argoCDRepoRoot, argocdOperatorRoot) + _, dexContainerInfo := upgradeCommonDefaultsGo(argoCDRepoRoot, argocdOperatorRoot) // update Argo CD crds - updateArgoCDCRDs(argoCDRepoRoot, argocdOperatorRoot) + updateArgoCDCRDs(argoCDRepoRoot, gitopsOperatorRoot) // update go.mod and regenerate the manifests/bundle: acquire target argo cd version, and ensures replace block matches upstream - updateGoModAndRegenBundle(targetArgoCDVersion, argocdOperatorRoot, argoCDRepoRoot) - - // update build/util/Dockerfile: update to reference target upstream container image - updateBuildUtilDockerfile(argocdOperatorRoot, argocdContainerInfo) + updateGoModAndRegenBundle(targetArgoCDVersion, argocdOperatorRoot, gitopsOperatorRoot, argoCDRepoRoot) // update controllers/argocd/dex_test.go: test references a dex container image replaceDexImageReferenceInDexTest(argocdOperatorRoot, dexContainerInfo) @@ -148,45 +167,6 @@ func replaceDexImageReferenceInDexTest(argocdOperatorRoot string, dexContainerIm } } -func updateBuildUtilDockerfile(argocdOperatorRoot string, argocdContainerImage *processedContainerImage) { - fileToUpdate := filepath.Join(argocdOperatorRoot, "build", "util", "Dockerfile") - - lines := utils.GetFileContentsAsLines(fileToUpdate) - - // Entry to replace in Dockerfile looks like this: - - // # Argo CD v3.1.0-rc2 - // FROM quay.io/argoproj/argocd@sha256:dc4e00548b9e9fe31b6b2dca99b2278390faabd3610a04f4707dfddf66b5e90d as argocd - - newContent := "" - - match := false - - for idx := 0; idx < len(lines); idx++ { - // Replace the argocd image references in the Dockerfile with new version - if strings.HasPrefix(lines[idx], "# Argo CD v") && strings.HasPrefix(lines[idx+1], "FROM quay.io/argoproj/argocd@sha256") { - newContent += "# Argo CD " + argocdContainerImage.version + "\n" - newContent += "FROM quay.io/argoproj/argocd@" + argocdContainerImage.sha256Digest + " AS argocd\n" - idx++ // Skip to the line after these 2 - match = true - } else { - newContent += lines[idx] + "\n" - } - } - - if !match { - utils.ExitWithError(fmt.Errorf("unable to locate reference to Argo CD container image in build/util/Dockerfile")) - return - } - - newContent = strings.TrimSpace(newContent) + "\n" - - if err := os.WriteFile(fileToUpdate, []byte(newContent), 0o600); err != nil { - utils.ExitWithError(fmt.Errorf("unable to update build/util/Dockerfile: %v", err)) - return - } -} - // upgrade common/defaults.go func upgradeCommonDefaultsGo(argoCDRepoRoot string, argocdOperatorRoot string) (*processedContainerImage, *processedContainerImage) { // Parse install YAML @@ -214,12 +194,10 @@ func upgradeCommonDefaultsGo(argoCDRepoRoot string, argocdOperatorRoot string) ( } targetRedisImage := utils.StripImagePrefix(targetRedisImageLine[0]) - targetHAProxyImageLine := utils.RemoveDuplicateLines(utils.GrepForString(installYAMLContents, "public.ecr.aws/docker/library/haproxy:")) - if len(targetHAProxyImageLine) != 1 { - utils.ExitWithError(fmt.Errorf("unexpected target haproxy image value: %v", targetHAProxyImageLine)) - return nil, nil - } - targetHAProxyImage := utils.StripImagePrefix(targetHAProxyImageLine[0]) + // ArgoCDDefaultRedisHAProxyVersion is intentionally not derived from Argo CD's manifests: + // the operator deliberately pins a newer haproxy (3.3.17-alpine, built with OpenSSL 3.5) + // than the one Argo CD references, so that TLS curve preferences can be configured for the + // Redis HA proxy. Update that constant manually when bumping haproxy. targetArgoCDImageLine := utils.RemoveDuplicateLines(utils.GrepForString(installYAMLContents, "quay.io/argoproj/argocd:")) if len(targetArgoCDImageLine) != 1 { @@ -233,42 +211,72 @@ func upgradeCommonDefaultsGo(argoCDRepoRoot string, argocdOperatorRoot string) ( fmt.Println("-", targetArgoCDImage) fmt.Println("-", targetDexImage) fmt.Println("-", targetRedisImage) - fmt.Println("-", targetHAProxyImage) - dexContainerInfo := retrieveSHA256DigestUsingSkopeo(targetDexImage) - redisContainerInfo := retrieveSHA256DigestUsingSkopeo(targetRedisImage) - haProxyContainerInfo := retrieveSHA256DigestUsingSkopeo(targetHAProxyImage) - argoCDContainerInfo := retrieveSHA256DigestUsingSkopeo(targetArgoCDImage) - - if err := replaceLineInCommonDefaultGo(argocdOperatorRoot, "ArgoCDDefaultArgoVersion", *argoCDContainerInfo); err != nil { - utils.ExitWithError(fmt.Errorf("unable to replace dex version: %v", err)) + dexContainerInfo, err := retrieveSHA256DigestUsingSkopeo(targetDexImage) + if err != nil { + utils.ExitWithError(fmt.Errorf("unable to resolve dex image digest: %v", err)) return nil, nil } - if err := replaceLineInCommonDefaultGo(argocdOperatorRoot, "ArgoCDDefaultDexVersion", *dexContainerInfo); err != nil { - utils.ExitWithError(fmt.Errorf("unable to replace dex version: %v", err)) + argoCDContainerInfo, err := retrieveSHA256DigestUsingSkopeo(targetArgoCDImage) + if err != nil { + utils.ExitWithError(fmt.Errorf("unable to resolve Argo CD image digest: %v", err)) return nil, nil } - if err := replaceLineInCommonDefaultGo(argocdOperatorRoot, "ArgoCDDefaultRedisVersionHA", *redisContainerInfo); err != nil { - utils.ExitWithError(fmt.Errorf("unable to replace redis HA version: %v", err)) + // Argo CD's install manifests reference redis on public.ecr.aws, but the operator deploys the + // mirrored image from quay.io (see ArgoCDDefaultRedisImage in common/defaults.go), so resolve + // the digest against the mirror. + redisTag, err := imageTagFromRef(targetRedisImage) + if err != nil { + utils.ExitWithError(err) return nil, nil } - if err := replaceLineInCommonDefaultGo(argocdOperatorRoot, "ArgoCDDefaultRedisVersion", *redisContainerInfo); err != nil { - utils.ExitWithError(fmt.Errorf("unable to replace redis version: %v", err)) + redisMirrorImage := redisMirrorImageURL + if override := os.Getenv(redisMirrorImageEnvVar); override != "" { + redisMirrorImage = override + } + + redisContainerInfo, redisErr := retrieveSHA256DigestUsingSkopeo(redisMirrorImage + ":" + redisTag) + if redisErr != nil { + // The mirror is a project-specific extra, not something Argo CD ships. Do not abort the + // whole dependency update when it lags upstream: keep the currently pinned redis digests + // (they still point at the previously shipped image) and let everything else update. + fmt.Println() + fmt.Printf("WARNING: unable to resolve redis digest for %s:%s: %v\n", redisMirrorImage, redisTag, redisErr) + fmt.Printf("WARNING: leaving ArgoCDDefaultRedisVersion and ArgoCDDefaultRedisVersionHA unchanged; re-run once the tag is mirrored, or set %s to an alternative mirror\n", redisMirrorImageEnvVar) + fmt.Println() + } + + if err := replaceLineInCommonDefaultGo(argocdOperatorRoot, "ArgoCDDefaultArgoVersion", *argoCDContainerInfo); err != nil { + utils.ExitWithError(fmt.Errorf("unable to replace dex version: %v", err)) return nil, nil } - if err := replaceLineInCommonDefaultGo(argocdOperatorRoot, "ArgoCDDefaultRedisHAProxyVersion", *haProxyContainerInfo); err != nil { - utils.ExitWithError(fmt.Errorf("unable to replace redis HA proxy version: %v", err)) + if err := replaceLineInCommonDefaultGo(argocdOperatorRoot, "ArgoCDDefaultDexVersion", *dexContainerInfo); err != nil { + utils.ExitWithError(fmt.Errorf("unable to replace dex version: %v", err)) return nil, nil } + if redisContainerInfo != nil { + if err := replaceLineInCommonDefaultGo(argocdOperatorRoot, "ArgoCDDefaultRedisVersionHA", *redisContainerInfo); err != nil { + utils.ExitWithError(fmt.Errorf("unable to replace redis HA version: %v", err)) + return nil, nil + } + + if err := replaceLineInCommonDefaultGo(argocdOperatorRoot, "ArgoCDDefaultRedisVersion", *redisContainerInfo); err != nil { + utils.ExitWithError(fmt.Errorf("unable to replace redis version: %v", err)) + return nil, nil + } + } + return argoCDContainerInfo, dexContainerInfo } -func updateArgoCDCRDs(argoCDRepoRoot string, argocdOperatorRoot string) { +// updateArgoCDCRDs copies the Argo CD CRDs into the gitops-operator module, which owns +// the operator manifests (config/crd/bases). +func updateArgoCDCRDs(argoCDRepoRoot string, gitopsOperatorRoot string) { argoCDCRDSourcePath := filepath.Join(argoCDRepoRoot, "manifests", "crds") entries, err := os.ReadDir(argoCDCRDSourcePath) @@ -304,7 +312,7 @@ func updateArgoCDCRDs(argoCDRepoRoot string, argocdOperatorRoot string) { for k, v := range filesToCopy { srcFile := filepath.Join(argoCDCRDSourcePath, k) - destFile := filepath.Join(argocdOperatorRoot, "config", "crd", "bases", v) + destFile := filepath.Join(gitopsOperatorRoot, "config", "crd", "bases", v) if err := utils.CopyFile(srcFile, destFile); err != nil { utils.ExitWithError(fmt.Errorf("unable to copy %s to %s: %v", srcFile, destFile, err)) @@ -450,29 +458,98 @@ func buildGitopsEngineReplaceLine(argoCDRepoRoot string, targetArgoCDVersion str ), nil } -// updateGoModAndRegenBundle ensures that argocd-operator go.mod is update to date with latest from upstream argocd-operator -func updateGoModAndRegenBundle(targetArgoCDVersion string, argocdOperatorRoot string, argoCDRepoRoot string) { - err := utils.RunCommandListWithWorkDir(argocdOperatorRoot, - [][]string{ - {"go", "get", "github.com/argoproj/argo-cd/v3@" + targetArgoCDVersion}, - }) - if err != nil { - utils.ExitWithError(fmt.Errorf("unable to update argocd-operator go.mod")) - return +// updateGoModAndRegenBundle ensures that both Go modules are up to date with the target Argo CD +// version, and regenerates the generated assets. argocd-operator is a submodule that carries the +// code, while the parent gitops-operator module owns config/, bundle/ and the vendor tree. +func updateGoModAndRegenBundle(targetArgoCDVersion string, argocdOperatorRoot string, gitopsOperatorRoot string, argoCDRepoRoot string) { + // Both modules require argo-cd directly, so bump them in lockstep. + for _, moduleRoot := range []string{argocdOperatorRoot, gitopsOperatorRoot} { + if err := utils.RunCommandListWithWorkDir(moduleRoot, + [][]string{ + {"go", "get", "github.com/argoproj/argo-cd/v3@" + targetArgoCDVersion}, + }); err != nil { + utils.ExitWithError(fmt.Errorf("unable to update go.mod in %s: %v", moduleRoot, err)) + return + } } copyGoModReplaceBlockFromArgoCDToArgoCDOperator(argoCDRepoRoot, argocdOperatorRoot, targetArgoCDVersion) - err = utils.RunCommandListWithWorkDir(argocdOperatorRoot, + // The parent module has its own replace block (it imports argo-cd directly too), so keep + // its gitops-engine pin in sync without overwriting its additional CVE/k8s replaces. + updateGitopsEngineReplaceLineInGoMod(filepath.Join(gitopsOperatorRoot, "go.mod"), argoCDRepoRoot, targetArgoCDVersion) + + for _, moduleRoot := range []string{argocdOperatorRoot, gitopsOperatorRoot} { + if err := utils.RunCommandListWithWorkDir(moduleRoot, + [][]string{ + {"go", "mod", "tidy"}, + {"go", "mod", "vendor"}, + }); err != nil { + utils.ExitWithError(fmt.Errorf("unable to tidy/vendor go.mod in %s: %v", moduleRoot, err)) + return + } + } + + // config/, bundle/ and the generated code live in the parent gitops-operator module. + err := utils.RunCommandListWithWorkDir(gitopsOperatorRoot, [][]string{ - {"go", "mod", "tidy"}, - {"rm", "-f", argocdOperatorRoot + "/bin/controller-gen"}, // Erase the controller-gen binary to ensure it is re-downloaded to the correct version + {"rm", "-f", filepath.Join(gitopsOperatorRoot, "bin", "controller-gen")}, // Erase the controller-gen binary to ensure it is re-downloaded to the correct version {"make", "generate", "manifests"}, {"make", "bundle"}, {"make", "fmt"}, }) if err != nil { - utils.ExitWithError(fmt.Errorf("unable to update argocd-operator go.mod")) + utils.ExitWithError(fmt.Errorf("unable to regenerate manifests/bundle in %s: %v", gitopsOperatorRoot, err)) + return + } + + if err := utils.RunCommandListWithWorkDir(argocdOperatorRoot, [][]string{{"go", "fmt", "./..."}}); err != nil { + utils.ExitWithError(fmt.Errorf("unable to run go fmt in %s: %v", argocdOperatorRoot, err)) + } +} + +// updateGitopsEngineReplaceLineInGoMod rewrites the gitops-engine replace directive (and the +// version comments around it) in the given go.mod. argo-cd overrides gitops-engine with a local +// replace that does not propagate to downstream modules, so every consumer must pin it to the +// argo-cd release commit. +func updateGitopsEngineReplaceLineInGoMod(goModPath string, argoCDRepoRoot string, targetArgoCDVersion string) { + replaceBlock, err := buildGitopsEngineReplaceLine(argoCDRepoRoot, targetArgoCDVersion) + if err != nil { + utils.ExitWithError(fmt.Errorf("unable to build gitops-engine replace line: %v", err)) + return + } + + replaceBlockLines := strings.Split(replaceBlock, "\n") + directive := replaceBlockLines[len(replaceBlockLines)-1] + + lines := utils.GetFileContentsAsLines(goModPath) + newContent := "" + matched := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "github.com/argoproj/argo-cd/gitops-engine =>"): + newContent += directive + "\n" + matched = true + case strings.HasPrefix(trimmed, "// This replace block is from Argo CD v"): + newContent += "\t// This replace block is from Argo CD " + targetArgoCDVersion + " go.mod\n" + case strings.HasPrefix(trimmed, "// v") && strings.Contains(trimmed, "declares gitops-engine at a pseudo-version"): + newContent += "\t// " + targetArgoCDVersion + " declares gitops-engine at a pseudo-version where go.mod\n" + case strings.HasPrefix(trimmed, "// Downstream consumers must resolve it themselves; pin to the v"): + newContent += "\t// Downstream consumers must resolve it themselves; pin to the " + targetArgoCDVersion + " commit.\n" + default: + newContent += line + "\n" + } + } + + if !matched { + utils.ExitWithError(fmt.Errorf("unable to find gitops-engine replace directive in %s", goModPath)) + return + } + + if err := os.WriteFile(goModPath, []byte(strings.TrimSpace(newContent)+"\n"), 0o600); err != nil { + utils.ExitWithError(fmt.Errorf("unable to write to file: %s %v", goModPath, err)) } } @@ -515,12 +592,29 @@ func replaceLineInCommonDefaultGo(pathToArgoCDGitRepoRoot string, variableToRepl return nil } +// imageTagFromRef returns the tag portion of a container image reference, ignoring any digest. +// A reference without an explicit tag yields an error rather than an implicit "latest", which +// would resolve a digest from an unrelated image. +func imageTagFromRef(ref string) (string, error) { + if idx := strings.Index(ref, "@"); idx >= 0 { + ref = ref[:idx] + } + + // Only treat a ':' after the last '/' as a tag separator, so registry ports are ignored. + if idx := strings.LastIndex(ref, ":"); idx > strings.LastIndex(ref, "/") { + if tag := ref[idx+1:]; tag != "" { + return tag, nil + } + } + + return "", fmt.Errorf("unable to determine tag from image reference %q: expected an explicit tag", ref) +} + // retrieveSHA256DigestUsingSkopeo determines the SHA256 digest value for a given container image -func retrieveSHA256DigestUsingSkopeo(url string) *processedContainerImage { +func retrieveSHA256DigestUsingSkopeo(url string) (*processedContainerImage, error) { stdout, _, err := utils.RunCommandWithWorkDir("", "skopeo", "inspect", "--no-tags", "--override-os", "linux", "--override-arch", "amd64", "docker://"+url) if err != nil { - utils.ExitWithError(fmt.Errorf("unexpected skopeo error: %v", err)) - return nil + return nil, fmt.Errorf("unexpected skopeo error: %w", err) } // Skopeo inspect output looks like this: @@ -537,26 +631,23 @@ func retrieveSHA256DigestUsingSkopeo(url string) *processedContainerImage { // Extract Digest value from JSON var jsonMap map[string]any if err := json.Unmarshal([]byte(stdout), &jsonMap); err != nil { - utils.ExitWithError(fmt.Errorf("unexpected unmarshal error: %v", err)) - return nil + return nil, fmt.Errorf("unexpected unmarshal error: %w", err) } digestVal, ok := jsonMap["Digest"] if !ok || digestVal == nil { - utils.ExitWithError(fmt.Errorf("unable to extract digest val for %s: key missing or nil", url)) - return nil + return nil, fmt.Errorf("unable to extract digest val for %s: key missing or nil", url) } sha256Digest, ok := digestVal.(string) if !ok || sha256Digest == "" { - utils.ExitWithError(fmt.Errorf("unable to extract digest val for %s: not a valid string", url)) - return nil + return nil, fmt.Errorf("unable to extract digest val for %s: not a valid string", url) } return &processedContainerImage{ version: url[strings.Index(url, ":")+1:], sha256Digest: sha256Digest, - } + }, nil } // processedContainerImage is the return values for processedContainerImage diff --git a/argocd-operator/hack/update-dependencies-script/gitops-promoter/main.go b/argocd-operator/hack/update-dependencies-script/gitops-promoter/main.go index 0dbc88a0baa..b54d1a0da59 100644 --- a/argocd-operator/hack/update-dependencies-script/gitops-promoter/main.go +++ b/argocd-operator/hack/update-dependencies-script/gitops-promoter/main.go @@ -4,26 +4,25 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "github.com/argoproj-labs/argocd-operator/dependency-upgrade/utils" ) const ( - GitOpsPromoterRepoURL = "https://github.com/argoproj-labs/gitops-promoter" - DefaultGitOpsPromoterVersion = "v0.35.1" -) + GitOpsPromoterRepoURL = "https://github.com/argoproj-labs/gitops-promoter" + gitOpsPromoterModulePath = "github.com/argoproj-labs/gitops-promoter" + gitOpsPromoterVersionEnvVar = "GITOPS_PROMOTER_VERSION" -var targetGitOpsPromoterVersion string + // promoterCRDFilePrefix matches the CRD files mirrored from the upstream repository into + // config/crd/bases, e.g. promoter.argoproj.io_promotionstrategies.yaml. + promoterCRDFilePrefix = "promoter.argoproj.io_" -func init() { - version := os.Getenv("GITOPS_PROMOTER_VERSION") - if version != "" { - targetGitOpsPromoterVersion = version - } else { - targetGitOpsPromoterVersion = DefaultGitOpsPromoterVersion - } -} + // kustomizationScaffoldResource is the marker in config/crd/kustomization.yaml before which + // new CRD resources are added by kubebuilder. + kustomizationScaffoldResource = "#+kubebuilder:scaffold:crdkustomizeresource" +) // TODO: Add debug statements func main() { @@ -39,14 +38,29 @@ func main() { return } + // argocd-operator is a Go submodule of the gitops-operator repository. The operator + // manifests (config/, bundle/) are maintained by the parent module. + gitopsOperatorRoot := filepath.Dir(argocdOperatorRoot) + + // The CRDs must match the gitops-promoter controller that is actually vendored, so the + // go.mod pin (bumped by dependabot) is the source of truth. This script does not update + // go.mod/vendor, so it must never copy CRDs from a different release. + argocdOperatorGoMod := filepath.Join(argocdOperatorRoot, "go.mod") + targetGitOpsPromoterVersion := readGitOpsPromoterVersionFromGoMod(argocdOperatorGoMod) + if override := os.Getenv(gitOpsPromoterVersionEnvVar); override != "" && override != targetGitOpsPromoterVersion { + utils.ExitWithError(fmt.Errorf("%s=%s does not match the %s version pinned in %s (%s); update go.mod/vendor first so the copied CRDs match the vendored controller", + gitOpsPromoterVersionEnvVar, override, gitOpsPromoterModulePath, argocdOperatorGoMod, targetGitOpsPromoterVersion)) + return + } + fmt.Println() - fmt.Println("Cloning GitOps Promoter Repo") + fmt.Println("Cloning GitOps Promoter Repo", targetGitOpsPromoterVersion) fmt.Println() // Clone GitOps Promoter into a temporary directory gitOpsPromoterRepoRoot, err := utils.CloneRepoIntoTempDir(GitOpsPromoterRepoURL, targetGitOpsPromoterVersion) if err != nil { - utils.ExitWithError(fmt.Errorf("unable to checkout Argo CD: %v", err)) + utils.ExitWithError(fmt.Errorf("unable to checkout GitOps Promoter %s: %v", targetGitOpsPromoterVersion, err)) return } @@ -56,33 +70,170 @@ func main() { return } + // Sanity test that the parent gitops-operator module (which owns config/ and bundle/) exists + if rootGoModExists, err := utils.FileExists(filepath.Join(gitopsOperatorRoot, "go.mod")); err != nil || !rootGoModExists { + utils.ExitWithError(fmt.Errorf("gitops-operator go.mod not found in '%s': %v", gitopsOperatorRoot, err)) + return + } + fmt.Println() fmt.Println("Copying CRDs") fmt.Println() - updateGitOpsPromoterCRDs(gitOpsPromoterRepoRoot, argocdOperatorRoot) + copiedCRDFiles := updateGitOpsPromoterCRDs(gitOpsPromoterRepoRoot, gitopsOperatorRoot) + updateGitOpsPromoterCRDKustomization(gitopsOperatorRoot, copiedCRDFiles) + + // config/manifests includes config/crd, so the bundle must be regenerated after the CRDs are + // copied. The aggregate update-dependencies runner processes the Argo CD updater first, whose + // bundle generation would otherwise leave the copied GitOps Promoter CRDs out of the bundle. + fmt.Println() + fmt.Println("Regenerating bundle") + fmt.Println() + + if err := utils.RunCommandListWithWorkDir(gitopsOperatorRoot, [][]string{{"make", "bundle"}}); err != nil { + utils.ExitWithError(fmt.Errorf("unable to regenerate bundle in %s: %v", gitopsOperatorRoot, err)) + return + } } -func updateGitOpsPromoterCRDs(promoterRepoRoot, operatorRoot string) { +// readGitOpsPromoterVersionFromGoMod returns the gitops-promoter version pinned in the given go.mod. +func readGitOpsPromoterVersionFromGoMod(goModPath string) string { + lines := utils.GetFileContentsAsLines(goModPath) + + var versions []string + for _, line := range lines { + fields := strings.Fields(line) + // Ignore replace directives ("module => ...") and unrelated dependencies. + if len(fields) < 2 || fields[0] != gitOpsPromoterModulePath || fields[1] == "=>" { + continue + } + versions = append(versions, fields[1]) + } + + if len(versions) != 1 { + utils.ExitWithError(fmt.Errorf("expected exactly one requirement for %s in %s, found %v", gitOpsPromoterModulePath, goModPath, versions)) + return "" + } + + return versions[0] +} + +// updateGitOpsPromoterCRDs copies the GitOps Promoter CRDs into the gitops-operator module, +// which owns the operator manifests (config/crd/bases), and returns the copied filenames. +func updateGitOpsPromoterCRDs(promoterRepoRoot, gitopsOperatorRoot string) []string { crdSourcePath := filepath.Join(promoterRepoRoot, "config", "crd", "bases") entries, err := os.ReadDir(crdSourcePath) if err != nil { utils.ExitWithError(fmt.Errorf("unable to list GitOps Promoter CRDs: %v", err)) - return + return nil } + var copied []string for _, entry := range entries { - if !strings.Contains(entry.Name(), "argoproj.io") || !strings.HasSuffix(entry.Name(), ".yaml") { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), promoterCRDFilePrefix) || !strings.HasSuffix(entry.Name(), ".yaml") { continue } srcFile := filepath.Join(crdSourcePath, entry.Name()) - destFile := filepath.Join(operatorRoot, "config", "crd", "bases", entry.Name()) + destFile := filepath.Join(gitopsOperatorRoot, "config", "crd", "bases", entry.Name()) if err := utils.CopyFile(srcFile, destFile); err != nil { utils.ExitWithError(fmt.Errorf("unable to copy %s to %s: %v", srcFile, destFile, err)) + return nil + } + + copied = append(copied, entry.Name()) + } + + // Sanity test: if upstream reorganized its CRD directory, the loop above would silently + // copy nothing and leave the operator pinned to the previous release. + if len(copied) == 0 { + utils.ExitWithError(fmt.Errorf("no GitOps Promoter CRDs found in %s; the upstream layout may have changed", crdSourcePath)) + return nil + } + + return copied +} + +// updateGitOpsPromoterCRDKustomization reconciles config/crd/kustomization.yaml with the CRDs +// copied from upstream: new CRD files are added, and entries (and mirrored files) for CRDs that +// upstream no longer ships are removed. config/manifests includes config/crd, so this list +// determines which CRDs end up in the OLM bundle. +func updateGitOpsPromoterCRDKustomization(gitopsOperatorRoot string, copiedCRDFiles []string) { + kustomizationPath := filepath.Join(gitopsOperatorRoot, "config", "crd", "kustomization.yaml") + basesDir := filepath.Join(gitopsOperatorRoot, "config", "crd", "bases") + + copied := make(map[string]bool, len(copiedCRDFiles)) + for _, fname := range copiedCRDFiles { + copied[fname] = true + } + + // Remove mirrored CRD files that upstream no longer ships, so they cannot linger in the bundle. + entries, err := os.ReadDir(basesDir) + if err != nil { + utils.ExitWithError(fmt.Errorf("unable to list CRDs in %s: %v", basesDir, err)) + return + } + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasPrefix(name, promoterCRDFilePrefix) || !strings.HasSuffix(name, ".yaml") || copied[name] { + continue + } + + if err := os.Remove(filepath.Join(basesDir, name)); err != nil { + utils.ExitWithError(fmt.Errorf("unable to remove stale CRD %s: %v", name, err)) + return + } + fmt.Println("Removed stale CRD", name) + } + + resources := make([]string, 0, len(copiedCRDFiles)) + for _, fname := range copiedCRDFiles { + resources = append(resources, "- bases/"+fname) + } + sort.Strings(resources) + + lines := utils.GetFileContentsAsLines(kustomizationPath) + + var ( + newLines []string + resourcesWritten bool + ) + writeResources := func() { + if resourcesWritten { return } + newLines = append(newLines, resources...) + resourcesWritten = true + } + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Replace the existing block of promoter CRD resources in place, regardless of which + // entries were added or removed upstream. + if strings.HasPrefix(trimmed, "- bases/"+promoterCRDFilePrefix) { + writeResources() + continue + } + + if !resourcesWritten && strings.HasPrefix(trimmed, kustomizationScaffoldResource) { + writeResources() + } + + newLines = append(newLines, line) + } + + if !resourcesWritten { + utils.ExitWithError(fmt.Errorf("unable to find where to add GitOps Promoter CRDs in %s; expected an existing '%s' resource or the '%s' marker", + kustomizationPath, promoterCRDFilePrefix, kustomizationScaffoldResource)) + return + } + + if err := os.WriteFile(kustomizationPath, []byte(strings.Join(newLines, "\n")), 0o600); err != nil { + utils.ExitWithError(fmt.Errorf("unable to update %s: %v", kustomizationPath, err)) + return } }