package main import ( "context" "encoding/json" "fmt" "html/template" "os" "os/exec" "os/signal" "syscall" "time" github "github.com/google/go-github/v68/github" ) // -------------------------------------------------------------------------- \\ // ---------------------------- File description ---------------------------- \\ // // #update-knative-components/main.go -- This file takes care of updating // knative components programatically. // // Files interacted with: // 1) The source-of-truth file and its content can be found at // root/hack/component-versions.json // 2) autogenerated script in root/hack/component-versions.sh (2 directories up) // // How to use this file: // Most of the time this file will be used in a workflow that will run // on scheduled basis checking if a new latest version of corresponding // components exists (check components in 'Versions' struct). Please note that // KindNode is NOT being updated programatically at this time. // When new latest version is detected, the program will create a PR in // knative/func repository with the latest changes allowing the CI/CD workflows // to run automatically before using the latest in main branch. // Alternative use: You can run this file from hack/ directory to locally // regenerate 2 files mentioned above (if you made some changes etc.) - you can // use the root Makefile for your convenience -- 'make regenerate-kn-components' // // -------------------------------------------------------------------------- \\ const ( fileScript string = "component-versions.sh" fileJson string = "component-versions.json" versionsScriptTemplate string = `#!/usr/bin/env bash # AUTOGENERATED FILE - edit versions in ./component-versions.json. # If you are adding components, modify this scripts' template in # ./cmd/update-knative-components/main.go. # You can regenerate locally with "make generate-kn-components-local". set_versions() { # Note: Kubernetes Version node image per Kind releases (full hash is suggested): # https://github.com/kubernetes-sigs/kind/releases kind_node_version={{.KindNode}} # gets updated programatically via workflow -> PR creation knative_serving_version="{{.Serving}}" knative_eventing_version="{{.Eventing}}" contour_version="{{.Contour}}" } ` ) // all the components that are kept up to date type Versions struct { KindNode string Serving string Eventing string Contour string } func main() { // there is an optional "local" argument if len(os.Args) == 2 && os.Args[1] == "local" { //leveraging lazy evals fmt.Println("Generate argument received! Regenerating files locally...") err := generateComponentVersions() if err != nil { fmt.Fprintf(os.Stderr, "error: %v", err) os.Exit(1) } fmt.Println("done") os.Exit(0) } // Set up context for possible signal inputs to not disrupt cleanup process. // This is not gonna do much for workflows since they finish and shutdown // but in case of local testing - dont leave left over resources on disk/RAM. ctx, cancel := context.WithCancel(context.Background()) defer cancel() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigs cancel() <-sigs os.Exit(130) }() const prTitle = "chore: Update components' versions to latest" client := github.NewClient(nil).WithAuthToken(os.Getenv("GITHUB_TOKEN")) e, err := prExists(ctx, client, prTitle) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if e { fmt.Printf("PR already exists, nothing to do, exiting") os.Exit(0) } projects := []struct { owner, repo string }{ { owner: "knative", repo: "serving", }, { owner: "knative", repo: "eventing", }, { owner: "knative-extensions", repo: "net-contour", }, } // Get current versions used. v, err := readVersions(fileJson) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } updated := false // cycle through all versions of components listed above, fetch their // latest from github releases - cmp them - create PR for update if necessary for _, p := range projects { newV, err := getLatestVersion(ctx, client, p.owner, p.repo) if err != nil { err = fmt.Errorf("error while getting latest v of %s/%s: %v", p.owner, p.repo, err) fmt.Fprintln(os.Stderr, err) os.Exit(1) } // try to overwrite with possibly new versions if updateVersion(&v, p.repo, newV) { // if any of the files are updated, set true updated = true } } if !updated { // nothing was updated, nothing to do fmt.Printf("all good, no newer component releases, exiting\n") os.Exit(0) } if err := writeFiles(v, fileScript, fileJson); err != nil { err = fmt.Errorf("failed to write files: %v", err) fmt.Fprintln(os.Stderr, err) os.Exit(1) } fmt.Println("files updated!") branchName := "update-components" + time.Now().Format(time.DateOnly) err = prepareBranch(branchName) if err != nil { err = fmt.Errorf("failed to prep the branch: %v", err) fmt.Fprintln(os.Stderr, err) os.Exit(1) } err = createPR(ctx, client, prTitle, branchName) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } // read (unmarshal) component versions from .json func readVersions(file string) (v Versions, err error) { fmt.Print("> Reading versions from file...") data, err := os.ReadFile(file) if err != nil { return } err = json.Unmarshal(data, &v) if err != nil { return v, err } fmt.Println("done") return v, nil } // attempt to update 'v' Versions to new 'val' for specific 'repo' // 'v' - structure that holds versions of components // 'repo' - points to a component repo that is being updated (example: serving) // 'val' - value of the latest release of that repo (pulled via GH API) func updateVersion(v *Versions, repo, val string) (updated bool) { fmt.Printf("check for %s update...", repo) if repo == "serving" && v.Serving != val { v.Serving = val updated = true } else if repo == "eventing" && v.Eventing != val { v.Eventing = val updated = true } else if repo == "net-contour" && v.Contour != val { v.Contour = val updated = true } if updated { fmt.Printf("found! new:'%s'\n", val) } else { fmt.Println("nothing to do") } return } // Overwrite the 'source of truth' file - .json and regenerate new script // with new versions from 'v'. // Arguments 'script' & 'json' are paths to files for autogenerated script and // source (json) file respectively. func writeFiles(v Versions, script, json string) error { fmt.Print("> Writing into files...") // write to json err := writeVersionsSource(v, json) if err != nil { return fmt.Errorf("failed to write to json: %v", err) } // write to script file err = writeVersionsScript(v, script) if err != nil { return fmt.Errorf("failed to generate script: %v", err) } fmt.Println("done") return nil } // write to 'source of truth' file (json) with updated versions func writeVersionsSource(v Versions, file string) error { vB, err := json.MarshalIndent(v, "", " ") if err != nil { return fmt.Errorf("cant Marshal versions: %v", err) } f, err := os.Create(file) if err != nil { return err } defer f.Close() _, err = f.Write(append(vB, '\n')) // append newline for reviewdog return err } // write to autogenerated script file with newest Versions via templates pkg func writeVersionsScript(v Versions, file string) error { tmpl, err := template.New("versions").Parse(versionsScriptTemplate) if err != nil { return err } f, err := os.Create(file) if err != nil { return err } defer f.Close() if err := tmpl.Execute(f, v); err != nil { return err } return nil } // get latest version of owner/repo via GH API func getLatestVersion(ctx context.Context, client *github.Client, owner string, repo string) (v string, err error) { fmt.Printf("> get latest '%s/%s'...", owner, repo) rr, res, err := client.Repositories.GetLatestRelease(ctx, owner, repo) if err != nil { err = fmt.Errorf("error: request for latest %s release: %v", owner+"/"+repo, err) return } if res.StatusCode < 200 && res.StatusCode > 299 { err = fmt.Errorf("error: Return status code of request for latest %s release is %d", owner+"/"+repo, res.StatusCode) return } v = *rr.Name if v == "" { return "", fmt.Errorf("internal error: returned latest release name is empty for '%s'", repo) } fmt.Println("done") return v, nil } // prepare branch for PR via git commands // config user as knative bot -> create branch -> add changes -> commit -> push func prepareBranch(branchName string) error { fmt.Println("> preparing branch") cmd := exec.Command("bash", "-c", fmt.Sprintf(` git config --local user.email "automation@knative.team" && git config --local user.name "Knative Automation" && git switch -c %s && git add %s %s && git commit -m "update components" && git push origin %s `, branchName, fileScript, fileJson, branchName)) cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout return cmd.Run() } // create a PR via GH API on the func repo with those new versions func createPR(ctx context.Context, client *github.Client, title string, branchName string) error { fmt.Print("> creating PR...") bodyText := "You might need to close & open this PR so all tests can run" body := fmt.Sprintf("%s\n%s\n/assign @gauron99", title, bodyText) newPR := github.NewPullRequest{ Title: github.Ptr(title), Base: github.Ptr("main"), Head: github.Ptr(branchName), Body: github.Ptr(body), MaintainerCanModify: github.Ptr(true), } pr, _, err := client.PullRequests.Create(ctx, "knative", "func", &newPR) if err != nil { fmt.Printf("PR looks like this:\n%#v\n", pr) fmt.Printf("err: %s\n", err) return err } fmt.Println("ready") return nil } // Returns true when PR with given title already exists in knative/func repo // otherwise false. // Returns an error if occurred, otherwise nil. func prExists(ctx context.Context, c *github.Client, title string) (bool, error) { perPage := 10 opt := &github.PullRequestListOptions{State: "open", ListOptions: github.ListOptions{PerPage: perPage}} for { list, resp, err := c.PullRequests.List(ctx, "knative", "func", opt) if err != nil { return false, fmt.Errorf("errror pulling PRs in knative/func: %s", err) } for _, pr := range list { if pr.GetTitle() == title { // gauron99 - currently cannot update already existing PR return true, nil } } if resp.NextPage == 0 { // hit end of list return false, nil } // otherwise, continue to the next page opt.Page = resp.NextPage } } // -------------------------------------------------------------------------- \\ // -------------------------------------------------------------------------- \\ // -------------------------------------------------------------------------- \\ // -------------------------------------------------------------------------- \\ // This is used when running this file with 1st argument "generate". // Regenerate written files (source (.json) & autogenerated .sh file) // Generally you wont use this, but in case you make local changes to the // files, you can simply regenerate them with this func generateComponentVersions() error { v, err := readVersions(fileJson) if err != nil { return fmt.Errorf("failed to read Versions from json: %v", err) } // generate err = writeFiles(v, fileScript, fileJson) if err != nil { return fmt.Errorf("failed to write Versions: %v", err) } return nil }