2020-10-14 00:44:08 +00:00
/ *
Copyright 2020 The Kubernetes Authors All rights reserved .
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
http : //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 .
* /
/ *
Script expects the following env variables :
- UPDATE_TARGET = < string > : optional - if unset / absent , default option is "fs" ; valid options are :
- "fs" - update only local filesystem repo files [ default ]
- "gh" - update only remote GitHub repo files and create PR ( if one does not exist already )
- "all" - update local and remote repo files and create PR ( if one does not exist already )
- GITHUB_TOKEN = < string > : GitHub [ personal ] access token
- note : GITHUB_TOKEN is required if UPDATE_TARGET is "gh" or "all"
* /
package update
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"text/template"
"time"
"github.com/cenkalti/backoff/v4"
2020-10-22 22:00:46 +00:00
"k8s.io/klog/v2"
2020-10-14 00:44:08 +00:00
)
const (
2020-10-22 22:00:46 +00:00
// FSRoot is a relative (to scripts in subfolders) root folder of local filesystem repo to update
2020-10-14 00:44:08 +00:00
FSRoot = "../../../"
)
var (
target = os . Getenv ( "UPDATE_TARGET" )
)
// init klog and check general requirements
func init ( ) {
2020-10-16 23:43:19 +00:00
klog . InitFlags ( nil )
2020-10-22 22:00:46 +00:00
if err := flag . Set ( "logtostderr" , "false" ) ; err != nil {
klog . Warningf ( "Unable to set flag value for logtostderr: %v" , err )
}
if err := flag . Set ( "alsologtostderr" , "true" ) ; err != nil {
klog . Warningf ( "Unable to set flag value for alsologtostderr: %v" , err )
2020-10-14 00:44:08 +00:00
}
flag . Parse ( )
defer klog . Flush ( )
if target == "" {
target = "fs"
} else if target != "fs" && target != "gh" && target != "all" {
klog . Fatalf ( "Invalid UPDATE_TARGET option: '%s'; Valid options are: unset/absent (defaults to 'fs'), 'fs', 'gh', or 'all'" , target )
} else if ( target == "gh" || target == "all" ) && ghToken == "" {
klog . Fatalf ( "GITHUB_TOKEN is required if UPDATE_TARGET is 'gh' or 'all'" )
}
}
2020-10-22 22:00:46 +00:00
// Item defines Content where all occurrences of each Replace map key,
// corresponding to GitHub TreeEntry.Path and/or local filesystem repo file path (prefixed with FSRoot),
// would be swapped with its respective actual map value (having placeholders replaced with data), creating a concrete update plan.
// Replace map keys can use RegExp and map values can use Golang Text Template.
2020-10-14 00:44:08 +00:00
type Item struct {
Content [ ] byte ` json:"-" `
Replace map [ string ] string ` json:"replace" `
}
2020-10-22 22:00:46 +00:00
// apply updates Item Content by replacing all occurrences of Replace map's keys with their actual map values (with placeholders replaced with data).
2020-11-15 04:41:12 +00:00
func ( i * Item ) apply ( data interface { } ) error {
2020-11-15 04:04:58 +00:00
if i . Content == nil {
2020-11-15 04:41:12 +00:00
return fmt . Errorf ( "unable to update content: nothing to update" )
2020-10-14 00:44:08 +00:00
}
2020-12-04 12:12:31 +00:00
str := string ( i . Content )
2020-10-14 00:44:08 +00:00
for src , dst := range i . Replace {
2020-11-15 04:04:58 +00:00
out , err := ParseTmpl ( dst , data , "" )
if err != nil {
2020-11-15 04:41:12 +00:00
return err
2020-10-14 00:44:08 +00:00
}
re := regexp . MustCompile ( src )
2020-11-15 04:04:58 +00:00
str = re . ReplaceAllString ( str , out )
2020-10-14 00:44:08 +00:00
}
i . Content = [ ] byte ( str )
2020-11-15 04:41:12 +00:00
return nil
2020-10-14 00:44:08 +00:00
}
// Apply applies concrete update plan (schema + data) to GitHub or local filesystem repo
func Apply ( ctx context . Context , schema map [ string ] Item , data interface { } , prBranchPrefix , prTitle string , prIssue int ) {
2020-11-15 04:04:58 +00:00
schema , pretty , err := GetPlan ( schema , data )
2020-10-14 00:44:08 +00:00
if err != nil {
2020-11-15 04:04:58 +00:00
klog . Fatalf ( "Unable to parse schema: %v\n%s" , err , pretty )
2020-10-14 00:44:08 +00:00
}
2020-11-15 04:04:58 +00:00
klog . Infof ( "The Plan:\n%s" , pretty )
2020-10-14 00:44:08 +00:00
if target == "fs" || target == "all" {
changed , err := fsUpdate ( FSRoot , schema , data )
if err != nil {
2020-10-22 22:00:46 +00:00
klog . Errorf ( "Unable to update local repo: %v" , err )
2020-10-14 00:44:08 +00:00
} else if ! changed {
klog . Infof ( "Local repo update skipped: nothing changed" )
} else {
2020-10-22 22:00:46 +00:00
klog . Infof ( "Local repo successfully updated" )
2020-10-14 00:44:08 +00:00
}
}
if target == "gh" || target == "all" {
// update prTitle replacing template placeholders with actual data values
2020-11-15 04:04:58 +00:00
if prTitle , err = ParseTmpl ( prTitle , data , "prTitle" ) ; err != nil {
2020-10-22 22:00:46 +00:00
klog . Fatalf ( "Unable to parse PR Title: %v" , err )
2020-10-14 00:44:08 +00:00
}
// check if PR already exists
prURL , err := ghFindPR ( ctx , prTitle , ghOwner , ghRepo , ghBase , ghToken )
if err != nil {
2020-10-22 22:00:46 +00:00
klog . Errorf ( "Unable to check if PR already exists: %v" , err )
2020-10-14 00:44:08 +00:00
} else if prURL != "" {
klog . Infof ( "PR create skipped: already exists (%s)" , prURL )
} else {
// create PR
pr , err := ghCreatePR ( ctx , ghOwner , ghRepo , ghBase , prBranchPrefix , prTitle , prIssue , ghToken , schema , data )
if err != nil {
2020-10-22 22:00:46 +00:00
klog . Fatalf ( "Unable to create PR: %v" , err )
2020-10-14 00:44:08 +00:00
} else if pr == nil {
klog . Infof ( "PR create skipped: nothing changed" )
} else {
2020-10-22 22:00:46 +00:00
klog . Infof ( "PR successfully created: %s" , * pr . HTMLURL )
2020-10-14 00:44:08 +00:00
}
}
}
}
2020-10-22 22:00:46 +00:00
// GetPlan returns concrete plan replacing placeholders in schema with actual data values, returns JSON-formatted representation of the plan and any error occurred.
2020-11-15 04:04:58 +00:00
func GetPlan ( schema map [ string ] Item , data interface { } ) ( plan map [ string ] Item , prettyprint string , err error ) {
plan = make ( map [ string ] Item )
for p , item := range schema {
path , err := ParseTmpl ( p , data , "" )
if err != nil {
return plan , fmt . Sprintf ( "%+v" , schema ) , err
}
plan [ path ] = item
}
for _ , item := range plan {
2020-10-14 00:44:08 +00:00
for src , dst := range item . Replace {
2020-11-15 04:04:58 +00:00
out , err := ParseTmpl ( dst , data , "" )
if err != nil {
return plan , fmt . Sprintf ( "%+v" , schema ) , err
2020-10-14 00:44:08 +00:00
}
2020-11-15 04:04:58 +00:00
item . Replace [ src ] = out
2020-10-14 00:44:08 +00:00
}
}
2020-11-15 04:04:58 +00:00
str , err := json . MarshalIndent ( plan , "" , " " )
2020-10-14 00:44:08 +00:00
if err != nil {
2020-11-15 04:04:58 +00:00
return plan , fmt . Sprintf ( "%+v" , schema ) , err
2020-10-14 00:44:08 +00:00
}
2020-11-15 04:04:58 +00:00
return plan , string ( str ) , nil
2020-10-14 00:44:08 +00:00
}
// RunWithRetryNotify runs command cmd with stdin using exponential backoff for maxTime duration
// up to maxRetries (negative values will make it ignored),
// notifies about any intermediary errors and return any final error.
// similar to pkg/util/retry/retry.go:Expo(), just for commands with params and also with context
func RunWithRetryNotify ( ctx context . Context , cmd * exec . Cmd , stdin io . Reader , maxTime time . Duration , maxRetries uint64 ) error {
be := backoff . NewExponentialBackOff ( )
be . Multiplier = 2
be . MaxElapsedTime = maxTime
bm := backoff . WithMaxRetries ( be , maxRetries )
bc := backoff . WithContext ( bm , ctx )
notify := func ( err error , wait time . Duration ) {
klog . Errorf ( "Temporary error running '%s' (will retry in %s): %v" , cmd . String ( ) , wait , err )
}
2021-08-13 01:11:16 +00:00
return backoff . RetryNotify ( func ( ) error {
2020-10-14 00:44:08 +00:00
cmd . Stdin = stdin
var stderr bytes . Buffer
cmd . Stderr = & stderr
if err := cmd . Run ( ) ; err != nil {
time . Sleep ( be . NextBackOff ( ) . Round ( 1 * time . Second ) )
return fmt . Errorf ( "%w: %s" , err , stderr . String ( ) )
}
return nil
2021-08-13 01:11:16 +00:00
} , bc , notify )
2020-10-14 00:44:08 +00:00
}
// Run runs command cmd with stdin
func Run ( cmd * exec . Cmd , stdin io . Reader ) error {
cmd . Stdin = stdin
var out bytes . Buffer
cmd . Stderr = & out
if err := cmd . Run ( ) ; err != nil {
return fmt . Errorf ( "%w: %s" , err , out . String ( ) )
}
return nil
}
2020-11-15 04:04:58 +00:00
// ParseTmpl replaces placeholders in text with actual data values
func ParseTmpl ( text string , data interface { } , name string ) ( string , error ) {
tmpl := template . Must ( template . New ( name ) . Parse ( text ) )
buf := new ( bytes . Buffer )
if err := tmpl . Execute ( buf , data ) ; err != nil {
return "" , err
}
return buf . String ( ) , nil
}