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