diff --git a/ops/gmpctl.sh b/ops/gmpctl.sh index 794a7a0828..9a45cb7120 100755 --- a/ops/gmpctl.sh +++ b/ops/gmpctl.sh @@ -26,6 +26,12 @@ SCRIPT_DIR="$( pwd -P )" +# Install dependencies in the current context. We switch Go toolchain versions so using go tool might not work. +go install github.com/google/go-containerregistry/cmd/gcrane@latest +go install github.com/mikefarah/yq/v4@latest +go install helm.sh/helm/v3/cmd/helm@latest +go install github.com/google/addlicense@latest + # NOTE gmpctl expects the gmpctl directory to be present on execution for # local bash scripts and configuration. # diff --git a/ops/gmpctl/README.md b/ops/gmpctl/README.md index 63dc69e149..ee42cc4d81 100644 --- a/ops/gmpctl/README.md +++ b/ops/gmpctl/README.md @@ -47,7 +47,7 @@ key information and confirmations e.g. same parameters, and it will continue the previous work or at least yield same results. This is crucial when iterating on breaking go mod updates for vulnerabilities or fork sync conflicts. -```text mdox-exec="bash ops/gmpctl.sh --help" +```text mdox-exec="bash -c \"bash ops/gmpctl.sh --help 2>&1 | sed -n '/Usage/,$p'\"" Usage: gmpctl [COMMAND] [FLAGS] -c string Path to the configuration file. See config.go#Config for the structure. (default ".gmpctl.default.yaml") @@ -67,6 +67,8 @@ Usage: gmpctl [COMMAND] [FLAGS] [vulnfix] Usage of vulnfix: -b string Release branch to work on; Project is auto-detected from this + -go-version string + Go minor version to use for docker images. -pr-branch string (default: $USER/BRANCH-vulnfix) Upstream branch to push to (user-confirmed first). -sync-dockerfiles-from diff --git a/ops/gmpctl/cmd_release.go b/ops/gmpctl/cmd_release.go index 2ce2a9f85d..0a0e6942d6 100644 --- a/ops/gmpctl/cmd_release.go +++ b/ops/gmpctl/cmd_release.go @@ -83,22 +83,22 @@ func release() error { return err } + mustCreateOrRecreateTag(dir, tag) if !mustIsRemoteUpToDate(dir, branch) { - if confirmf("About to git push state from %q to \"origin/%v\" for %q tag; are you sure?", dir, branch, tag) { - // We are in detached state, so use the HEAD reference. - mustPush(dir, fmt.Sprintf("HEAD:%v", branch)) + if confirmf("About to create a signed tag %q and git push state (HEAD:%v) and tag %q from %q to \"origin\"; are you sure?", tag, branch, tag, dir) { + mustPush(dir, fmt.Sprintf("HEAD:%v", branch), tag) } else { return errors.New("aborting") } - } - - // TODO(bwplotka): Check if tag exists. - mustCreateSignedTag(dir, tag) - if confirmf("About to git push %q tag from %q to \"origin/%v\"; are you sure?", tag, dir, branch) { - mustPush(dir, tag) } else { - return errors.New("aborting") + // Retagging only. This can happen if someone wants to continue the script. + if confirmf("About to create a signed tag %q and push it from %q to \"origin\"; are you sure?", tag, dir) { + mustPush(dir, tag) + } else { + return errors.New("aborting") + } } + if confirmf("Do you want to remove the %v worktree (recommended)?", dir) { proj.RemoveWorkDir(cfg.Directory, dir) } diff --git a/ops/gmpctl/cmd_vulnfix.go b/ops/gmpctl/cmd_vulnfix.go index ef94a61744..a7b8dc8160 100644 --- a/ops/gmpctl/cmd_vulnfix.go +++ b/ops/gmpctl/cmd_vulnfix.go @@ -19,6 +19,10 @@ import ( "flag" "fmt" "os" + "os/exec" + "path/filepath" + "regexp" + "strings" ) var ( @@ -26,6 +30,7 @@ var ( vulnfixBranch = vulnfixFlags.String("b", "", "Release branch to work on; Project is auto-detected from this") vulnfixPRBranch = vulnfixFlags.String("pr-branch", "", "(default: $USER/BRANCH-vulnfix) Upstream branch to push to (user-confirmed first).") vulnfixSyncDockerfilesFrom = vulnfixFlags.Bool("sync-dockerfiles-from", false, "Optional branch name to sync Dockerfiles from. Useful when things changed.") + vulnfixGoVersion = vulnfixFlags.String("go-version", "", "Go minor version to use for docker images.") ) // Attempt a minimal dependency upgrade to solve fixable vulnerabilities. @@ -74,21 +79,46 @@ func vulnfix() error { // Refresh. mustFetchAll(dir) + goVersion := *vulnfixGoVersion + if goVersion == "" { + goVersion, err = detectGoMinorVersion(dir) + if err != nil { + return fmt.Errorf("could not detect Go version from Dockerfile: %v", err) + } + } + logf("Using Go version: %s", goVersion) + opts := []string{ fmt.Sprintf("DIR=%v", dir), fmt.Sprintf("BRANCH=%v", branch), fmt.Sprintf("PROJECT=%v", proj.Name), + fmt.Sprintf("GO_VERSION=%v", goVersion), + // We are hardcoding toolchain everywhere for now, until we have deps that require higher version. + // This makes it simpler to maintain dependencies across old versions, forks and tools (e.g. code gen). + // This follows what e.g. Prometheus is doing https://github.com/prometheus/prometheus/pull/18938#issue-4676291443 + fmt.Sprintf("GOTOOLCHAIN=go1.25.0"), } if *vulnfixSyncDockerfilesFrom { opts = append(opts, "SYNC_DOCKERFILES_FROM=true") } + // Update go version in go.mod to what toolchain is set to if it was updated by accident + // otherwise it won't work with our toolchain. + if _, err := runCommand(&cmdOpts{Dir: dir, Envs: opts}, "go", "mod", "edit", "-go=1.25.0"); err != nil { + return fmt.Errorf("failed to update go version in go.mod: %v", err) + } // TODO(bwplotka): Add NPM vulnfix. if err := runLocalBash(dir, opts, "vulnfix.sh"); err != nil { return err } - // TODO: Warn of unstaged files at this point. + if proj.Name != "prometheus-engine" { + if err := fixOtelSchemaConflict(dir); err != nil { + return err + } + } + + // TODO: Warn of any unstaged files at this point. // Commit if anything is staged. msg := fmt.Sprintf("google patch[deps]: fix %v vulnerabilities", branch) @@ -110,6 +140,7 @@ func vulnfix() error { // We are in detached state, so be explicit what to push and from where, by recreating the local prBranch. mustRecreateBranch(dir, prBranch) mustForcePush(dir, prBranch) + mustEnsurePullRequest(dir, branch, prBranch, msg, "Updating Go and image vulnerabilities using"+wrapCode("./gmpctl.sh vulnfix")) } else { return errors.New("aborting") } @@ -119,3 +150,153 @@ func vulnfix() error { } return nil } + +func detectGoMinorVersion(dir string) (string, error) { + var dockerfiles []string + err := filepath.WalkDir(dir, func(path string, info os.DirEntry, err error) error { + if err != nil { + return err + } + if info.IsDir() { + name := info.Name() + if name == "third_party" || name == "ui" || name == "vendor" || name == "node_modules" || name == ".git" { + return filepath.SkipDir + } + return nil + } + if strings.HasPrefix(info.Name(), "Dockerfile") { + dockerfiles = append(dockerfiles, path) + } + return nil + }) + if err != nil { + return "", err + } + if len(dockerfiles) == 0 { + return "", fmt.Errorf("no Dockerfile found in %s", dir) + } + + re := regexp.MustCompile(`(?:google-go\.pkg\.dev/golang|golang):([0-9]+\.[0-9]+)`) + + for _, df := range dockerfiles { + content, err := os.ReadFile(df) + if err != nil { + continue + } + matches := re.FindSubmatch(content) + if len(matches) > 1 { + return string(matches[1]), nil + } + } + return "", fmt.Errorf("could not find golang image in any Dockerfile under %s", dir) +} + +func wrapCode(s string) string { + return "\n```\n" + s + "\n```\n" +} + +// It's a common occurrence that schema import goes off-sync with the go module, fix it. +func fixOtelSchemaConflict(dir string) error { + targetVersion, err := detectSchemaVersion(dir) + if err != nil { + return err + } + if targetVersion == "" { + return nil + } + return replaceOtelImports(dir, targetVersion) +} + +// TODO(bwplotka): AI figured some way, but there's likely a better way to tell? +func detectSchemaVersion(dir string) (string, error) { + tmpFile := filepath.Join(dir, "gmpctl_tmp_schema.go") + tmpCode := `package main + +import ( + "fmt" + "go.opentelemetry.io/otel/sdk/resource" +) + +func main() { + r := resource.Default() + fmt.Print(r.SchemaURL()) +} +` + if err := os.WriteFile(tmpFile, []byte(tmpCode), 0o644); err != nil { + return "", fmt.Errorf("failed to write temp file: %w", err) + } + defer os.Remove(tmpFile) + + cmd := exec.Command("go", "run", "gmpctl_tmp_schema.go") + cmd.Dir = dir + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + // If it fails to run, it might be because otel/sdk is not in dependencies, + // or some other issue. We log and ignore to not block the whole pipeline if it's not relevant. + logf("Warning: failed to run temp schema detector: %v", err) + return "", nil + } + + schemaURL := string(out) + if schemaURL == "" { + logf("No schema URL detected from SDK resource") + return "", nil + } + + reVersion := regexp.MustCompile(`([0-9]+\.[0-9]+\.[0-9]+)$`) + matches := reVersion.FindStringSubmatch(schemaURL) + if len(matches) < 2 { + logf("Could not parse version from schema URL: %s", schemaURL) + return "", nil + } + return "v" + matches[1], nil +} + +func replaceOtelImports(dir string, targetVersion string) error { + logf("Detected target OpenTelemetry schema version: %s", targetVersion) + + reImport := regexp.MustCompile(`"go\.opentelemetry\.io/otel/semconv/(v1\.[0-9]+\.[0-9]+)"`) + reSchemaURLUse := regexp.MustCompile(`\.SchemaURL\b`) + + if err := filepath.WalkDir(dir, func(path string, info os.DirEntry, err error) error { + if err != nil { + return err + } + if info.IsDir() { + name := info.Name() + if name == "vendor" || name == "third_party" || name == ".git" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(info.Name(), ".go") { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return err + } + + if !reImport.Match(content) || !reSchemaURLUse.Match(content) { + return nil + } + + newContent := reImport.ReplaceAllFunc(content, func(match []byte) []byte { + return []byte(fmt.Sprintf(`"go.opentelemetry.io/otel/semconv/%s"`, targetVersion)) + }) + + if string(newContent) != string(content) { + logf("Updating OTEL semconv imports to %s in %s", targetVersion, path) + if err := os.WriteFile(path, newContent, 0o644); err != nil { + return fmt.Errorf("failed to write file %s: %w", path, err) + } + } + return nil + }); err != nil { + return err + } + mustAddAll(dir) + return nil +} diff --git a/ops/gmpctl/git.go b/ops/gmpctl/git.go index 4de71c9dbe..4ca7ef9dec 100644 --- a/ops/gmpctl/git.go +++ b/ops/gmpctl/git.go @@ -49,21 +49,68 @@ func mustFetchAll(dir string) { } } +func getTTY(dir string) string { + out, err := runCommand(&cmdOpts{Dir: dir, HideOutputs: true}, "tty") + if err != nil { + return "" + } + return strings.TrimSpace(out) +} + func mustCreateSignedTag(dir, tag string) { logf("Creating a signed tag %v...", tag) - // explicit TTY is often needed on Macs. + // Ensure TTY is set for GPG signing. + var envs []string + if tty := getTTY(dir); tty != "" { + envs = append(envs, "GPG_TTY="+tty) + } + // TODO(bwplotka): Consider adding v0.x second tag for Prometheus fork (similar to how v0.300 Prometheus releases are structured). // This is to have a little bit cleaner prometheus-engine go.mod version against the fork. if _, err := runCommand( - &cmdOpts{Dir: dir}, - "bash", "-c", - fmt.Sprintf("GPG_TTY=$(tty) git tag -s %v -m %v", tag, tag), + &cmdOpts{Dir: dir, Envs: envs}, + "git", "tag", "-s", tag, "-m", tag, ); err != nil { panicf(err.Error()) } } +func mustCreateOrRecreateTag(dir, tag string) { + // Check if the tag exists on origin, and if so, finish + _, errLs := runCommand(&cmdOpts{Dir: dir, HideOutputs: true}, "git", "ls-remote", "--exit-code", "--tags", "origin", "refs/tags/"+tag) + if errLs == nil { + panicf("This tag %v was already pushed, chose a different one!", tag) + } + + // Check if the tag already exists locally. + tagCommit, err := runCommand(&cmdOpts{Dir: dir, HideOutputs: true}, "git", "rev-parse", "--verify", "-q", "refs/tags/"+tag+"^{commit}") + if err == nil { + // Tag exists locally! + headCommit, err := runCommand(&cmdOpts{Dir: dir, HideOutputs: true}, "git", "rev-parse", "HEAD") + if err != nil { + panicf("failed to get HEAD commit: %v", err) + } + + tagCommit = strings.TrimSpace(tagCommit) + headCommit = strings.TrimSpace(headCommit) + + if tagCommit == headCommit { + logf("Tag %q already exists locally and points to HEAD (%s). Skipping recreation.", tag, headCommit) + return + } + logf("Tag %q exists locally but points to commit %s, while HEAD is at %s. Re-tagging...", tag, tagCommit, headCommit) + + // Delete the local tag. + if _, err := runCommand(&cmdOpts{Dir: dir}, "git", "tag", "-d", tag); err != nil { + panicf("failed to delete local tag %s: %v", tag, err) + } + } + + // Create the signed tag. + mustCreateSignedTag(dir, tag) +} + // mustIsRemoteUpToDate returns true if HEAD points to the same commit as // the origin branch func mustIsRemoteUpToDate(dir, branch string) bool { @@ -84,9 +131,10 @@ func mustIsRemoteUpToDate(dir, branch string) bool { return strings.TrimSpace(localHead) == strings.TrimSpace(remoteHead) } -func mustPush(dir, what string) { - logf("Pushing %v...", what) - if _, err := runCommand(&cmdOpts{Dir: dir}, "git", "push", "origin", what); err != nil { +func mustPush(dir string, what ...string) { + logf("Pushing %v...", strings.Join(what, " ")) + args := append([]string{"git", "push", "origin"}, what...) + if _, err := runCommand(&cmdOpts{Dir: dir}, args...); err != nil { panicf("failed to push: %v", err) } } @@ -106,6 +154,12 @@ func mustRecreateBranch(dir, branch string) { } } +func mustAddAll(dir string) { + if _, err := runCommand(&cmdOpts{Dir: dir}, "git", "add", "--all"); err != nil { + panicf("failed to git add all: %v", err) + } +} + func checkoutBranch(dir, branchName string) { if _, err := runCommand(&cmdOpts{Dir: dir}, "git", "checkout", branchName); err != nil { panicf(err.Error()) diff --git a/ops/gmpctl/github.go b/ops/gmpctl/github.go new file mode 100644 index 0000000000..d22056d4f7 --- /dev/null +++ b/ops/gmpctl/github.go @@ -0,0 +1,67 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "strings" +) + +type prInfo struct { + URL string `json:"url"` +} + +func mustEnsurePullRequest(dir, baseBranch, headBranch, title, body string) { + logf("Checking for existing pull request for %v...", headBranch) + + // gh pr list --head --base --state open --json url + out, err := runCommand( + &cmdOpts{Dir: dir, HideOutputs: true}, + "gh", "pr", "list", + "--head", headBranch, + "--base", baseBranch, + "--state", "open", + "--json", "url", + ) + if err != nil { + panicf("failed to check existing PR: %v", err) + } + + var prs []prInfo + if err := json.Unmarshal([]byte(out), &prs); err != nil { + panicf("failed to parse gh output: %v, output: %q", err, out) + } + + if len(prs) > 0 { + logf("Pull request already exists: %v", prs[0].URL) + return + } + + logf("Creating pull request from %v to %v...", headBranch, baseBranch) + + // gh pr create --title --body <body> --base <base> --head <head> + prURL, err := runCommand( + &cmdOpts{Dir: dir}, + "gh", "pr", "create", + "--title", title, + "--body", body, + "--base", baseBranch, + "--head", headBranch, + ) + if err != nil { + panicf("failed to create pull request: %v", err) + } + logf("Pull request created: %v", strings.TrimSpace(prURL)) +} diff --git a/ops/gmpctl/gmp.go b/ops/gmpctl/gmp.go index 937ae8fc59..4d27bb3428 100644 --- a/ops/gmpctl/gmp.go +++ b/ops/gmpctl/gmp.go @@ -62,6 +62,8 @@ func projectFromBranch(branch string) (Project, bool) { return Alertmanager, true case PrometheusEngine.BranchRE.MatchString(branch): return PrometheusEngine, true + case branch == "main": + return PrometheusEngine, true } return Project{}, false } diff --git a/ops/gmpctl/lib.sh b/ops/gmpctl/lib.sh index 3e0d4bdaeb..196f48f461 100755 --- a/ops/gmpctl/lib.sh +++ b/ops/gmpctl/lib.sh @@ -244,7 +244,7 @@ release-lib::dockerfiles() { log_err "dir arg is required." return 1 fi - find "${dir}" -name "Dockerfile*" | grep -v "${dir}/third_party/" | grep -v "${dir}/hack/" | grep -v "${dir}/ui/" | grep -v "vendor/" | grep -v "node_modules/" + find "${dir}" \( -name "third_party" -o -name "ui" -o -name "vendor" -o -name "node_modules" -o -name ".git" \) -prune -o -name "Dockerfile*" -print } # Return all images used in a Dockerfile, delimited by new-line. @@ -407,8 +407,6 @@ release-lib::idemp::manifests_bash_image_bump() { return 1 fi - go install github.com/mikefarah/yq/v4@latest - local values_file="${dir}/charts/values.global.yaml" # TODO: Not enough, this has to check actual manifests. local bash_tag=$(yq '.images.bash.tag' "${values_file}") @@ -442,17 +440,22 @@ release-lib::manifests_regen() { return 1 fi - # TODO(bwplotka): Manage deps better. It's getting confusing what bins we should use (worktree bingo? script bingo?). - go install helm.sh/helm/v3/cmd/helm@latest - go install github.com/google/addlicense@latest - go install github.com/mikefarah/yq/v4@latest - # Hack: Do the bingo variable swap. This allows injecting our own. # This is faster than running requiring bingo and running bingo get. - cp "${dir}/.bingo/variables.env" "${dir}/.bingo/variables.env.bak" - echo "#!/bin/bash" >"${dir}/.bingo/variables.env" # Clean the file. + # NOTE: Only needed before 0.19. + if [[ -f "${dir}/.bingo/variables.env" ]]; then + cp "${dir}/.bingo/variables.env" "${dir}/.bingo/variables.env.bak" + echo "#!/bin/bash" >"${dir}/.bingo/variables.env" # Clean the file. + fi + + echo "🔄 Regenerating manifests..." + # Regenerate. YQ="$(which yq)" HELM="$(which helm)" ADDLICENSE="$(which addlicense)" bash "${dir}/hack/presubmit.sh" manifests - cp "${dir}/.bingo/variables.env.bak" "${dir}/.bingo/variables.env" + + # NOTE: Only needed before 0.19. + if [[ -f "${dir}/.bingo/variables.env.bak" ]]; then + mv "${dir}/.bingo/variables.env.bak" "${dir}/.bingo/variables.env" + fi echo "✅ Manifests regenerated" return 0 diff --git a/ops/gmpctl/main_test.go b/ops/gmpctl/main_test.go new file mode 100644 index 0000000000..f83eda1bb2 --- /dev/null +++ b/ops/gmpctl/main_test.go @@ -0,0 +1,203 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectGoMinorVersion(t *testing.T) { + for _, tt := range []struct { + name string + files map[string]string + expected string + }{ + { + name: "google-go.pkg.dev image", + files: map[string]string{ + "Dockerfile": ` +FROM --platform=$BUILDPLATFORM google-go.pkg.dev/golang:1.26.2@sha256:1bee769a7a50eea7730ac31f75182ae2614f50a70902407312db390a7c7cb2ff AS buildbase +ARG TARGETOS +`, + }, + expected: "1.26", + }, + { + name: "standard golang image", + files: map[string]string{ + "Dockerfile": ` +FROM golang:1.23.5 AS build +`, + }, + expected: "1.23", + }, + { + name: "skip directories", + files: map[string]string{ + "third_party/Dockerfile": "FROM golang:1.20.0", + "hack/Dockerfile": "FROM golang:1.20.0", + "ui/Dockerfile": "FROM golang:1.20.0", + "vendor/Dockerfile": "FROM golang:1.20.0", + "node_modules/Dockerfile": "FROM golang:1.20.0", + "Dockerfile": "FROM golang:1.24.1", + }, + expected: "1.24", + }, + } { + t.Run(tt.name, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "gmpctl-test") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + for path, content := range tt.files { + fullPath := filepath.Join(tempDir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + version, err := detectGoMinorVersion(tempDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if version != tt.expected { + t.Errorf("expected version %s, got %s", tt.expected, version) + } + }) + } +} + +func TestReplaceOtelImports(t *testing.T) { + for _, tt := range []struct { + name string + files map[string]string + targetVersion string + expected map[string]string + }{ + { + name: "replace import when SchemaURL is used", + files: map[string]string{ + "tracing.go": `package tracing +import ( + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" +) +func init() { + _ = semconv.SchemaURL +} +`, + }, + targetVersion: "v1.40.0", + expected: map[string]string{ + "tracing.go": `package tracing +import ( + semconv "go.opentelemetry.io/otel/semconv/v1.40.0" +) +func init() { + _ = semconv.SchemaURL +} +`, + }, + }, + { + name: "do not replace import when SchemaURL is NOT used", + files: map[string]string{ + "queue.go": `package queue +import ( + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" +) +func init() { + _ = semconv.HTTPResendCount +} +`, + }, + targetVersion: "v1.40.0", + expected: map[string]string{ + "queue.go": `package queue +import ( + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" +) +func init() { + _ = semconv.HTTPResendCount +} +`, + }, + }, + { + name: "replace with alias", + files: map[string]string{ + "tracing.go": `package tracing +import ( + conventions "go.opentelemetry.io/otel/semconv/v1.39.0" +) +func init() { + _ = conventions.SchemaURL +} +`, + }, + targetVersion: "v1.40.0", + expected: map[string]string{ + "tracing.go": `package tracing +import ( + conventions "go.opentelemetry.io/otel/semconv/v1.40.0" +) +func init() { + _ = conventions.SchemaURL +} +`, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "gmpctl-otel-test") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + for path, content := range tt.files { + fullPath := filepath.Join(tempDir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + if _, err := runCommand(&cmdOpts{Dir: tempDir, HideOutputs: true}, "git", "init", "-b", "main"); err != nil { + t.Fatal(err) + } + + err = replaceOtelImports(tempDir, tt.targetVersion) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for path, expectedContent := range tt.expected { + fullPath := filepath.Join(tempDir, path) + content, err := os.ReadFile(fullPath) + if err != nil { + t.Fatal(err) + } + if string(content) != expectedContent { + t.Errorf("file %s: expected content:\n%s\ngot:\n%s", path, expectedContent, string(content)) + } + } + }) + } +} diff --git a/ops/gmpctl/vulnfix.sh b/ops/gmpctl/vulnfix.sh index b1c5fbbc29..14f9565fc9 100644 --- a/ops/gmpctl/vulnfix.sh +++ b/ops/gmpctl/vulnfix.sh @@ -33,9 +33,6 @@ fi source "${SCRIPT_DIR}/lib.sh" -# TODO: Find better way. Go tool grane is tricky as we run in different directory. -go install github.com/google/go-containerregistry/cmd/gcrane@latest - # Also accepts SYNC_DOCKERFILES_FROM. if [[ -z "${DIR}" ]]; then @@ -53,6 +50,11 @@ if [[ -z "${PROJECT}" ]]; then exit 1 fi +if [[ -z "${GO_VERSION}" ]]; then + log_err "GO_VERSION envvar is required." + exit 1 +fi + echo "${DIR}" echo "${SCRIPT_DIR}" @@ -71,18 +73,12 @@ fi # Docker images bumps. -# Get first dockerfile Go version. We will use this version to find minor version to stick to. -go_version=$(release-lib::dockerfile_go_version "${DOCKERFILES[0]}") -if [[ -z "${go_version}" ]]; then - echo "❌ can't find any golang image in ${DOCKERFILES[0]}" - exit 1 -fi - # TODO: git add charts & vendor for old projects? # Update our images. for dockerfile in "${DOCKERFILES[@]}"; do - release-lib::dockerfile_update_image "${dockerfile}" "google-go.pkg.dev/golang" $(echo "${go_version}" | cut -d '.' -f 1-2) + # TOOD(bwplotka): Bump gcr.io/distroless/static-debian12:nonroot images as well https://github.com/GoogleCloudPlatform/prometheus-engine/pull/1933 + release-lib::dockerfile_update_image "${dockerfile}" "google-go.pkg.dev/golang" $(echo "${GO_VERSION}" | cut -d '.' -f 1-2) release-lib::dockerfile_update_image "${dockerfile}" "gke.gcr.io/gke-distroless/libc" "gke_distroless_" pushd "${DIR}" git add "${dockerfile}" @@ -116,7 +112,6 @@ if [[ "no vulnerabilities" != $(cat "${vuln_file}") ]]; then fi # Check if that helped. - echo "⚠️ This will fail on older branches with vendoring; in this case, simply go to ${DIR}, run 'go mod vendor' and rerun." release-lib::vulnlist "${DIR}" "${vuln_file}" if [[ "no vulnerabilities" != $(cat "${vuln_file}") ]]; then echo "❌ After go mod update some vulnerabilities are still found; go to ${DIR} and resolve it manually (select not reusing the ./vulnlist.txt file) and rerun." diff --git a/ops/gmpctl/vulnupdatelist/nvdapi.go b/ops/gmpctl/vulnupdatelist/nvdapi.go index 3a59085f7c..f914697441 100644 --- a/ops/gmpctl/vulnupdatelist/nvdapi.go +++ b/ops/gmpctl/vulnupdatelist/nvdapi.go @@ -21,9 +21,15 @@ import ( "log/slog" "net/http" "strings" + "sync" "time" ) +var ( + severityCache = make(map[string]string) + cacheMu sync.RWMutex +) + // NVDResponse is the top-level object for the NVD CVE API. type NVDResponse struct { Vulnerabilities []struct { @@ -35,6 +41,11 @@ type NVDResponse struct { BaseSeverity string `json:"baseSeverity"` } `json:"cvssData"` } `json:"cvssMetricV31"` + CVSSMetricV30 []struct { + CVSSData struct { + BaseSeverity string `json:"baseSeverity"` + } `json:"cvssData"` + } `json:"cvssMetricV30"` } `json:"metrics"` } `json:"cve"` } `json:"vulnerabilities"` @@ -42,6 +53,13 @@ type NVDResponse struct { // getCVSSSeverity fetches vulnerability details from the NVD API and returns the CVSS V3 severity. func getCVSSSeverity(apiKey, cveID string) (string, error) { + cacheMu.RLock() + if sev, ok := severityCache[cveID]; ok { + cacheMu.RUnlock() + return sev, nil + } + cacheMu.RUnlock() + // https://nvd.nist.gov/developers/vulnerabilities apiURL := fmt.Sprintf("https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=%s", cveID) @@ -75,11 +93,23 @@ func getCVSSSeverity(apiKey, cveID string) (string, error) { if len(nvdResponse.Vulnerabilities) > 0 { metrics := nvdResponse.Vulnerabilities[0].CVE.Metrics + var sev string if len(metrics.CVSSMetricV31) > 0 { - return metrics.CVSSMetricV31[0].CVSSData.BaseSeverity, nil + sev = metrics.CVSSMetricV31[0].CVSSData.BaseSeverity + } else if len(metrics.CVSSMetricV30) > 0 { + sev = metrics.CVSSMetricV30[0].CVSSData.BaseSeverity + } + if sev != "" { + cacheMu.Lock() + severityCache[cveID] = sev + cacheMu.Unlock() + return sev, nil } } + cacheMu.Lock() + severityCache[cveID] = "UNKNOWN" + cacheMu.Unlock() return "UNKNOWN", nil } diff --git a/tools/go.mod b/tools/go.mod index 8db9a71d86..eff29a18d4 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -99,7 +99,7 @@ require ( github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/containerd/containerd v1.7.33 // indirect + github.com/containerd/containerd v1.7.32 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect diff --git a/tools/go.sum b/tools/go.sum index 45d94c3907..f6aac52339 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -347,8 +347,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= -github.com/containerd/containerd v1.7.33 h1:iAkYGC/ifR/V+0eR4iXWHNGYUF0DF2PmGV5iz4Irj5M= -github.com/containerd/containerd v1.7.33/go.mod h1:gSbSCVjPCdkfJCjyrzz7aRC+xFlqVbatNpfHfVCYGUM= +github.com/containerd/containerd v1.7.32 h1:S54xuVcPxeLaYgaRABtpJ2VyVUVsy0IGf7qHBs+sbY8= +github.com/containerd/containerd v1.7.32/go.mod h1:jdwD6s/BhV4XVJGrvtziNPVA+83n66TwptVaPKprq4E= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=