package update import ( "context" "crypto/sha1" //nolint:gosec // not used for security. git content hashing "errors" "fmt" "io/fs" "os" "os/exec" "strings" "github.com/google/go-github/v47/github" "edge-infra.dev/hack/tools/apko-updater-bot/internal" githubclient "edge-infra.dev/pkg/f8n/devinfra/github-client" "edge-infra.dev/pkg/f8n/devinfra/github/ghfs" "edge-infra.dev/pkg/lib/build/apko" "edge-infra.dev/pkg/lib/cli/rags" "edge-infra.dev/pkg/lib/cli/sink" ) const ( apkoUpdateCommitBranchName = "chore/apko-lock-bot-update-files" commitMessageTemplate = "apko.lock.json files updated by the apko-lock-bot:\n" prTitle = "chore(build): Update rules_apko apko.lock.json files" ) func New() *sink.Command { var ( configPath string repository string owner string baseBranch string mergeBranch string commitBranch string apkoLocations string dryRun bool ghPrivateKeyPath string ghPrivateKey string ghAppID int64 ghInstallationID int64 ) cmd := &sink.Command{ Use: "update [flags]", Short: "Update the apko.lock.json files based on directories passed in containing apko.yaml and lock file pairs", Flags: []*rags.Rag{ { Name: "config-path", Usage: "path to a configuration .yaml file. this configuration overrides all passed flags", Value: &rags.String{ Var: &configPath, }, }, { Name: "owner", Usage: "the owner of the repository in which to perform the PR", Value: &rags.String{ Var: &owner, Default: "ncrvoyix-swt-retail", }, }, { Name: "repository", Usage: "the repository in which to perform the PR", Value: &rags.String{ Var: &repository, Default: "edge-infra", }, }, { Name: "base-branch", Usage: "the base branch to perform the PR against", Value: &rags.String{ Var: &baseBranch, Default: "master", }, }, { Name: "merge-branch", Usage: "name of the branch to perform the PR against", Value: &rags.String{ Var: &mergeBranch, Default: "master", }, }, { Name: "commit-branch", Usage: "the commit branch to merge in", Value: &rags.String{ Var: &commitBranch, Default: apkoUpdateCommitBranchName, }, }, { Name: "apko-locations", Usage: "a comma-separated list of directories that contain apko.yaml and apko.lock.json files", Value: &rags.String{Var: &apkoLocations}, }, { Name: "dry-run", Usage: "dry run the commit and PR", Value: &rags.Bool{Var: &dryRun, Default: false}, }, { Name: "gh-app-private-key-path", Usage: "private key path for the Github app - mutually exclusive with gh-app-private-key", Value: &rags.String{Var: &ghPrivateKeyPath}, }, { Name: "gh-app-private-key", Usage: "the value of the Github app private key - mutually exclusive with gh-app-private-key-path", Value: &rags.String{Var: &ghPrivateKey}, }, { Name: "gh-app-id", Usage: "id of the Github app", Value: &rags.Int64{Var: &ghAppID}, }, { Name: "gh-installation-id", Usage: "id of the Github app", Value: &rags.Int64{Var: &ghInstallationID}, }, }, Exec: func(ctx context.Context, r sink.Run) error { if configPath != "" { r.Log.Info(fmt.Sprintf("loading values from config at path %s", configPath)) updaterConfig, err := internal.NewConfigFromPath(configPath) if err != nil { r.Log.Error(err, "error creating config") return err } owner = updaterConfig.Owner repository = updaterConfig.Repository baseBranch = updaterConfig.BaseBranch mergeBranch = updaterConfig.MergeBranch commitBranch = updaterConfig.CommitBranch apkoLocations = strings.Join(updaterConfig.ApkoLocations, ",") dryRun = updaterConfig.DryRun ghPrivateKey = updaterConfig.GHPrivateKey ghPrivateKeyPath = updaterConfig.GHPrivateKeyPath ghAppID = updaterConfig.GHAppID ghInstallationID = updaterConfig.GHInstallationID } privateKeyBytes, privateKeyErr := getPrivateKey(ghPrivateKeyPath, ghPrivateKey) if privateKeyErr != nil { return privateKeyErr } repoURL := fmt.Sprintf("https://github.com/%s/%s", owner, repository) config := githubclient.Config{ Repository: repoURL, AppID: ghAppID, InstallationID: ghInstallationID, PrivateKey: privateKeyBytes, } client, err := githubclient.NewClient(config) if err != nil { return err } if client.Client == nil { return fmt.Errorf("the Github client.Client is nil") } r.Log.Info("authenticated with Github app") r.Log.Info("creating Github FS for base branch") baseFS, err := ghfs.New(ctx, client.Client, owner, repository, baseBranch) if err != nil { return err } r.Log.Info("creating Github FS for commit branch") commitBranchFS, commitBranchFSNotFoundErr := ghfs.New(ctx, client.Client, owner, repository, commitBranch) if commitBranchFSNotFoundErr != nil { if commitBranchFSNotFoundErr == commitBranchFSNotFoundErr.(ghfs.RefNotFoundError) { r.Log.Info(fmt.Sprintf("no branch %s available at origin - will create at PR creation time", commitBranch)) } else { return commitBranchFSNotFoundErr } } var updateFS *ghfs.FS var fsBranch string if commitBranchFSNotFoundErr == nil { r.Log.Info("using the commit branch FS") updateFS = commitBranchFS fsBranch = commitBranch } else { r.Log.Info("using the base branch FS") updateFS = baseFS fsBranch = baseBranch } // Get the apko.yamls to lock and check locks for drift apkoDirs := strings.Split(apkoLocations, ",") apkoPairs := []internal.APKOFilePair{} // Create the temp files for _, apkoDir := range apkoDirs { r.Log.Info(fmt.Sprintf("reading %s from the FS", apkoDir)) pair, err := internal.APKOPathToFilePair(updateFS, apkoDir) if err != nil && errors.Unwrap(err) == fs.ErrNotExist { r.Log.Info(fmt.Sprintf("warning: %s does not exist in the repo - skipping", apkoDir)) continue } else if err != nil { return err } apkoPairs = append(apkoPairs, pair) } // Defer deletion to keep disk tidy for _, pair := range apkoPairs { defer os.RemoveAll(pair.APKOLocalTempDir) } commitBody := commitMessageTemplate // Run apko lock on the temp apko.yaml and see what happens apkoEntries := []*github.TreeEntry{} for _, pair := range apkoPairs { apkoEntries, err = loadAPKOTreeEntries(r, pair, apkoEntries) if err != nil { return err } commitBody = fmt.Sprintf("%supdated %s\n", commitBody, pair.APKOLockPath) } if len(apkoEntries) == 0 { r.Log.Info("all apkos for branch are up to date - exiting") return nil } if dryRun { r.Log.Info("dry-run is set, exiting") return nil } var updateRef *github.Reference // If fsBranch == commitRef, get the ref switch fsBranch { case commitBranch: r.Log.Info("getting the upstream in-flight commit branch") updateRef, _, err = client.Git.GetRef(ctx, owner, repository, fmt.Sprintf("refs/heads/%s", commitBranch)) if err != nil { return err } // If fsBranch == baseBranch, need to create ref case baseBranch: r.Log.Info("creating the commit branch based on the base branch") baseRef, _, err := client.Git.GetRef(ctx, owner, repository, fmt.Sprintf("refs/heads/%s", baseBranch)) if err != nil { return err } newRef := &github.Reference{Ref: github.String(fmt.Sprintf("refs/heads/%s", commitBranch)), Object: &github.GitObject{SHA: baseRef.Object.SHA}} updateRef, _, err = client.Git.CreateRef(ctx, owner, repository, newRef) if err != nil { return err } default: return fmt.Errorf("update branch unknown state") } // Push the commit r.Log.Info("pushing commit", updateRef.Object.GetSHA()) err = pushCommit( ctx, client.Client, updateRef, apkoEntries, owner, repository, commitBody, ) if err != nil { return err } prOpts := &github.PullRequestListOptions{Head: fmt.Sprintf("%s:%s", owner, updateRef.GetRef())} prList, _, err := client.PullRequests.List(ctx, owner, repository, prOpts) if len(prList) == 0 { r.Log.Info(fmt.Sprintf("PR for %s does not exist yet - creating", commitBranch)) pr, err := createPR(ctx, client.Client, owner, repository, mergeBranch, commitBranch, prTitle, commitBody) r.Log.Info(fmt.Sprintf("created pr %d", pr.GetNumber())) r.Log.Info(pr.GetHTMLURL()) if err != nil { return err } } else if err != nil { r.Log.Error(err, "err from list PRs") return err } else { for _, pr := range prList { if pr.Head.GetRef() == updateRef.String() { r.Log.Info(fmt.Sprintf("found existing PR #%d for branch %s", pr.GetNumber(), commitBranch)) r.Log.Info(pr.GetHTMLURL()) } } } return nil }, } return cmd } // BlobSHA1 calculates the Git SHA1 hash value for the provided content. Git prepends info to the content before taking // the SHA1 hash which is why you must call this function when working with blob SHA1 values. func BlobSHA1(content string) string { const ( null byte = 0 format = "blob %d%c%s" ) var blob = fmt.Sprintf(format, len(content), null, content) return fmt.Sprintf("%x", sha1.Sum([]byte(blob))) //nolint:gosec // not used for security } // Takes an APKO pair and runs lock on the apko.yaml // If the lock needs to be updated, the passed pair will be locked, converted into a // TreeEntry, and the slice of TreeEntries with the new entry will be passed back // If the lock does not need to be updated, the original entry slice is returned func loadAPKOTreeEntries(r sink.Run, pair internal.APKOFilePair, entries []*github.TreeEntry) ([]*github.TreeEntry, error) { cmd := exec.Command("apko") args := []string{"lock", pair.APKOFileLocalPath} cmd.Args = append(cmd.Args, args...) r.Log.Info(fmt.Sprintf("running apko lock on %s", pair.APKOPath)) cmd.Stdout = r.Out() cmd.Stderr = r.Err() err := cmd.Run() if err != nil { return nil, err } newLockBytes, err := os.ReadFile(fmt.Sprintf("%s/%s", pair.APKOLocalTempDir, apko.APKOLockFileName)) if err != nil { return nil, err } baseLockSha := BlobSHA1(string(pair.APKOLockFileContent)) newLockSha := BlobSHA1(string(newLockBytes)) r.Log.Info(fmt.Sprintf("comparing %s to fresh %s", pair.APKOFileLocalPath, apko.APKOLockFileName)) if baseLockSha == newLockSha { r.Log.Info(fmt.Sprintf("lock for %s matches - no changes required", pair.APKOPath)) return entries, nil } r.Log.Info(fmt.Sprintf("lock file for %s has drifted - committing to tree", pair.APKOPath)) r.Log.Info(fmt.Sprintf("origin sha %s did not match new lock sha %s", baseLockSha, newLockSha)) apkoTreeEntry := &github.TreeEntry{ Path: github.String(pair.APKOLockPath), Type: github.String("blob"), Mode: github.String(pair.APKOLockFileMode), Content: github.String(string(newLockBytes)), } entries = append(entries, apkoTreeEntry) return entries, nil } func pushCommit(ctx context.Context, client *github.Client, updateRef *github.Reference, entries []*github.TreeEntry, owner string, repository string, commitBody string) error { parent, _, err := client.Repositories.GetCommit(ctx, owner, repository, *updateRef.Object.SHA, nil) if err != nil { return err } // Not always populated? Copied from example code parent.Commit.SHA = parent.SHA tree, _, err := client.Git.CreateTree(ctx, owner, repository, *updateRef.Object.SHA, entries) if err != nil { return err } // Commit author is pulled from the installation token used during the client creation commit := &github.Commit{Message: github.String(commitBody), Tree: tree, Parents: []*github.Commit{parent.Commit}} newCommit, _, err := client.Git.CreateCommit(ctx, owner, repository, commit) if err != nil { return err } updateRef.Object.SHA = newCommit.SHA _, _, err = client.Git.UpdateRef(ctx, owner, repository, updateRef, false) return err } func createPR(ctx context.Context, client *github.Client, owner string, repo string, mergeBranch string, commitBranch string, prTitle string, prBody string) (*github.PullRequest, error) { newPR := &github.NewPullRequest{ Title: github.String(prTitle), Head: github.String(commitBranch), Base: github.String(mergeBranch), Body: github.String(prBody), MaintainerCanModify: github.Bool(true), } pr, _, err := client.PullRequests.Create(ctx, owner, repo, newPR) if err != nil { return nil, err } return pr, nil } func getPrivateKey(keyPath string, key string) ([]byte, error) { if key != "" && keyPath != "" { return nil, fmt.Errorf("gh-app-private-key-path and gh-app-private-key cannot both be set") } if key == "" && keyPath == "" { return nil, fmt.Errorf("either gh-app-private-key or gh-app-private-key-path must be set for Github app authentication") } var ghPrivateKeyPathMissingErr error var privateKeyBytes []byte if keyPath != "" { privateKeyBytes, ghPrivateKeyPathMissingErr = os.ReadFile(keyPath) if ghPrivateKeyPathMissingErr != nil { return nil, ghPrivateKeyPathMissingErr } } else if key != "" { privateKeyBytes = []byte(key) } return privateKeyBytes, nil }