go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/luciexe/build/main.go (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package build
    16  
    17  import (
    18  	"bytes"
    19  	"compress/zlib"
    20  	"context"
    21  	"encoding/json"
    22  	"flag"
    23  	"fmt"
    24  	"io"
    25  	"os"
    26  	"path/filepath"
    27  	"runtime/debug"
    28  	"time"
    29  
    30  	"golang.org/x/time/rate"
    31  	"google.golang.org/protobuf/proto"
    32  
    33  	bbpb "go.chromium.org/luci/buildbucket/proto"
    34  	"go.chromium.org/luci/common/errors"
    35  	"go.chromium.org/luci/common/logging"
    36  	"go.chromium.org/luci/common/logging/gologger"
    37  	"go.chromium.org/luci/common/system/environ"
    38  	"go.chromium.org/luci/logdog/client/butlerlib/bootstrap"
    39  	"go.chromium.org/luci/logdog/client/butlerlib/streamclient"
    40  	"go.chromium.org/luci/lucictx"
    41  	"go.chromium.org/luci/luciexe"
    42  )
    43  
    44  var errNonSuccess = errors.New("build state != SUCCESS")
    45  
    46  // Main implements all the 'command-line' behaviors of the luciexe 'exe'
    47  // protocol, including:
    48  //
    49  //   - parsing command line for "--output", "--help", etc.
    50  //   - parsing stdin (as appropriate) for the incoming Build message
    51  //   - creating and configuring a logdog client to send State evolutions.
    52  //   - Configuring a logdog client from the environment.
    53  //   - Writing Build state updates to the logdog "build.proto" stream.
    54  //   - Start'ing the build in this process.
    55  //   - End'ing the build with the returned error from your function
    56  //
    57  // If `inputMsg` is nil, the top-level properties will be ignored.
    58  //
    59  // If `writeFnptr` and `mergeFnptr` are nil, they're ignored. Otherwise
    60  // they work as they would for MakePropertyModifier.
    61  //
    62  // CLI Arguments parsed:
    63  //   - -h / --help : Print help for this binary (including input/output
    64  //     property type info)
    65  //   - --strict-input : Enable strict property parsing (see OptStrictInputProperties)
    66  //   - --output : luciexe "output" flag; See
    67  //     https://pkg.go.dev/go.chromium.org/luci/luciexe#hdr-Recursive_Invocation
    68  //   - --working-dir : The working directory to run from; Default is $PWD unless
    69  //     LUCIEXE_FAKEBUILD is set, in which case a temp dir is used and cleaned up after.
    70  //   - -- : Any extra arguments after a "--" token are passed to your callback
    71  //     as-is.
    72  //
    73  // Example:
    74  //
    75  //	func main() {
    76  //	  input := *MyInputProps{}
    77  //	  var writeOutputProps func(*MyOutputProps)
    78  //	  var mergeOutputProps func(*MyOutputProps)
    79  //
    80  //	  Main(input, &writeOutputProps, &mergeOutputProps, func(ctx context.Context, args []string, st *build.State) error {
    81  //	    // actual build code here, build is already Start'd
    82  //	    // input was parsed from build.Input.Properties
    83  //	    writeOutputProps(&MyOutputProps{...})
    84  //	    return nil // will mark the Build as SUCCESS
    85  //	  })
    86  //	}
    87  //
    88  // Main also supports running a luciexe build locally by specifying the
    89  // LUCIEXE_FAKEBUILD environment variable. The value of the variable should
    90  // be a path to a file containing a JSON-encoded Build proto.
    91  //
    92  // TODO(iannucci): LUCIEXE_FAKEBUILD does not support nested invocations.
    93  // It should set up bbagent and butler in order to aggregate logs.
    94  //
    95  // NOTE: These types are pretty bad; There's significant opportunity to improve
    96  // them with Go2 generics.
    97  func Main(inputMsg proto.Message, writeFnptr, mergeFnptr any, cb func(context.Context, []string, *State) error) {
    98  	ctx := gologger.StdConfig.Use(context.Background())
    99  
   100  	switch err := main(ctx, os.Args, os.Stdin, inputMsg, writeFnptr, mergeFnptr, cb); err {
   101  	case nil:
   102  		os.Exit(0)
   103  
   104  	case errNonSuccess:
   105  		os.Exit(1)
   106  	default:
   107  		errors.Log(ctx, err)
   108  		os.Exit(2)
   109  	}
   110  }
   111  
   112  func main(ctx context.Context, args []string, stdin io.Reader, inputMsg proto.Message, writeFnptr, mergeFnptr any, cb func(context.Context, []string, *State) error) (err error) {
   113  	args = append([]string(nil), args...)
   114  	var userArgs []string
   115  	for i, a := range args {
   116  		if a == "--" {
   117  			userArgs = args[i+1:]
   118  			args = args[:i]
   119  			break
   120  		}
   121  	}
   122  
   123  	opts := []StartOption{
   124  		OptParseProperties(inputMsg),
   125  	}
   126  	if writeFnptr != nil || mergeFnptr != nil {
   127  		opts = append(opts, OptOutputProperties(writeFnptr, mergeFnptr))
   128  	}
   129  
   130  	outputFile, wd, strict, help := parseArgs(args)
   131  	if strict {
   132  		opts = append(opts, OptStrictInputProperties())
   133  	}
   134  	if wd != "" {
   135  		if err := os.Chdir(wd); err != nil {
   136  			return err
   137  		}
   138  	}
   139  
   140  	var initial *bbpb.Build
   141  	var lastBuild *bbpb.Build
   142  
   143  	defer func() {
   144  		if outputFile != "" && lastBuild != nil {
   145  			if err := luciexe.WriteBuildFile(outputFile, lastBuild); err != nil {
   146  				logging.Errorf(ctx, "failed to write outputFile: %s", err)
   147  			}
   148  		}
   149  	}()
   150  
   151  	if !help {
   152  		if path := environ.FromCtx(ctx).Get(luciexeFakeVar); path != "" {
   153  			var cleanup func(*error)
   154  			ctx, initial, cleanup, err = prepFromFakeLuciexeEnv(ctx, wd, path)
   155  			if cleanup != nil {
   156  				defer cleanup(&err)
   157  			}
   158  		} else {
   159  			var moreOpts []StartOption
   160  			initial, moreOpts, err = prepOptsFromLuciexeEnv(ctx, stdin, &lastBuild)
   161  			if err != nil {
   162  				return err
   163  			}
   164  			opts = append(opts, moreOpts...)
   165  		}
   166  	}
   167  
   168  	state, ictx, err := Start(ctx, initial, opts...)
   169  	if err != nil {
   170  		return err
   171  	}
   172  
   173  	if help {
   174  		logging.Infof(ctx, "`%s` is a `luciexe` binary. See go.chromium.org/luci/luciexe.", args[0])
   175  		logging.Infof(ctx, "======= I/O Proto =======")
   176  
   177  		buf := &bytes.Buffer{}
   178  		state.SynthesizeIOProto(buf)
   179  		logging.Infof(ctx, "%s", buf.Bytes())
   180  		return nil
   181  	}
   182  
   183  	runUserCb(ictx, userArgs, state, cb)
   184  	if state.buildPb.Output.Status != bbpb.Status_SUCCESS {
   185  		return errNonSuccess
   186  	}
   187  	return nil
   188  }
   189  
   190  // overridden in tests
   191  var mainSendRate = rate.Every(time.Second)
   192  
   193  const luciexeFakeVar = "LUCIEXE_FAKEBUILD"
   194  
   195  func prepFromFakeLuciexeEnv(ctx context.Context, wd, buildPath string) (newCtx context.Context, initial *bbpb.Build, cleanup func(*error), err error) {
   196  	// This is a fake build.
   197  	logging.Infof(ctx, "Running fake build because %s is set. Build proto path: %s", luciexeFakeVar, buildPath)
   198  
   199  	// Pull the Build proto from the path in the environment variable and
   200  	// set up the environment according to the LUCI User Code Contract.
   201  	newCtx = ctx
   202  	env := environ.FromCtx(ctx)
   203  
   204  	// Defensively clear the environment variable so it doesn't propagate to subtasks.
   205  	env.Remove(luciexeFakeVar)
   206  
   207  	// Read the Build proto.
   208  	initial, err = readLuciexeFakeBuild(buildPath)
   209  	if err != nil {
   210  		return
   211  	}
   212  
   213  	if wd == "" {
   214  		// Create working directory heirarchy in a new temporary directory.
   215  		wd, err = os.MkdirTemp("", fmt.Sprintf("luciexe-fakebuild-%s-", filepath.Base(os.Args[0])))
   216  		if err != nil {
   217  			return
   218  		}
   219  		err = os.Chdir(wd)
   220  		if err != nil {
   221  			return
   222  		}
   223  		cleanup = func(err *error) {
   224  			logging.Infof(ctx, "Cleaning up working directory: %s", wd)
   225  			if r := os.RemoveAll(wd); r != nil && *err == nil {
   226  				*err = r
   227  			} else if r != nil {
   228  				logging.Errorf(ctx, "Failed to clean up working directory: %s", wd)
   229  			}
   230  		}
   231  	}
   232  	logging.Infof(ctx, "Working directory: %s", wd)
   233  
   234  	tmpDir := filepath.Join(wd, "tmp")
   235  	if err = os.MkdirAll(tmpDir, os.ModePerm); err != nil {
   236  		return
   237  	}
   238  	cacheDir := filepath.Join(wd, "cache")
   239  	if err = os.MkdirAll(cacheDir, os.ModePerm); err != nil {
   240  		return
   241  	}
   242  
   243  	// Set up environment and LUCI_CONTEXT.
   244  	for _, key := range luciexe.TempDirEnvVars {
   245  		env.Set(key, tmpDir)
   246  	}
   247  	newCtx = env.SetInCtx(newCtx)
   248  	newCtx = lucictx.SetLUCIExe(newCtx, &lucictx.LUCIExe{
   249  		CacheDir: cacheDir,
   250  	})
   251  
   252  	return
   253  }
   254  
   255  func prepOptsFromLuciexeEnv(ctx context.Context, stdin io.Reader, lastBuild **bbpb.Build) (initial *bbpb.Build, opts []StartOption, err error) {
   256  	initial, err = readStdinBuild(stdin)
   257  	if err != nil {
   258  		return
   259  	}
   260  
   261  	bs, err := bootstrap.GetFromEnv(environ.FromCtx(ctx))
   262  	if err != nil {
   263  		return
   264  	}
   265  
   266  	buildStream, err := bs.Client.NewDatagramStream(
   267  		ctx, "build.proto",
   268  		streamclient.WithContentType(luciexe.BuildProtoZlibContentType))
   269  	if err != nil {
   270  		return
   271  	}
   272  
   273  	zlibBuf := &bytes.Buffer{}
   274  	zlibWriter := zlib.NewWriter(zlibBuf)
   275  
   276  	opts = append(opts,
   277  		OptLogsink(bs.Client),
   278  		OptSend(mainSendRate, func(vers int64, build *bbpb.Build) {
   279  			*lastBuild = build
   280  			data, err := proto.Marshal(build)
   281  			if err != nil {
   282  				panic(err)
   283  			}
   284  
   285  			zlibBuf.Reset()
   286  			zlibWriter.Reset(zlibBuf)
   287  
   288  			if _, err := zlibWriter.Write(data); err != nil {
   289  				panic(err)
   290  			}
   291  			if err := zlibWriter.Close(); err != nil {
   292  				panic(err)
   293  			}
   294  			if err := buildStream.WriteDatagram(zlibBuf.Bytes()); err != nil {
   295  				panic(err)
   296  			}
   297  		}),
   298  	)
   299  
   300  	return
   301  }
   302  
   303  func runUserCb(ctx context.Context, userArgs []string, state *State, cb func(context.Context, []string, *State) error) {
   304  	var err error
   305  	defer func() {
   306  		state.End(err)
   307  		if thing := recover(); thing != nil {
   308  			logging.Errorf(ctx, "recovered panic: %s", thing)
   309  			logging.Errorf(ctx, "traceback:\n%s", debug.Stack())
   310  		}
   311  	}()
   312  	err = cb(ctx, userArgs, state)
   313  }
   314  
   315  func readStdinBuild(stdin io.Reader) (*bbpb.Build, error) {
   316  	data, err := io.ReadAll(stdin)
   317  	if err != nil {
   318  		return nil, errors.Annotate(err, "reading *Build from stdin").Err()
   319  	}
   320  
   321  	ret := &bbpb.Build{}
   322  	err = proto.Unmarshal(data, ret)
   323  	return ret, err
   324  }
   325  
   326  func readLuciexeFakeBuild(filename string) (*bbpb.Build, error) {
   327  	// N.B. This proto is JSON-encoded.
   328  	data, err := os.ReadFile(filename)
   329  	if err != nil {
   330  		return nil, errors.Annotate(err, "reading *Build from %s=%s", luciexeFakeVar, filename).Err()
   331  	}
   332  	ret := &bbpb.Build{}
   333  	err = json.Unmarshal(data, ret)
   334  	return ret, errors.Annotate(err, "decoding *Build from %s=%s", luciexeFakeVar, filename).Err()
   335  }
   336  
   337  func parseArgs(args []string) (output, wd string, strict, help bool) {
   338  	fs := flag.FlagSet{}
   339  	fs.BoolVar(&strict, "strict-input", false, "Strict input parsing; Input properties supplied which aren't understood by this program will print an error and quit.")
   340  	fs.StringVar(&wd, "working-dir", "", "The working directory to run from; Default is $PWD unless LUCIEXE_FAKEBUILD is set, in which case a temp dir is used and cleaned up after.")
   341  	fs.StringVar(&output, "output", "", "Output final Build message to this path. Must end with {.json,.pb,.textpb}")
   342  	fs.BoolVar(&help, "help", false, "Print help for this executable")
   343  	fs.BoolVar(&help, "h", false, "Print help for this executable")
   344  	if err := fs.Parse(args[1:]); err != nil {
   345  		panic(err)
   346  	}
   347  	return
   348  }