go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/cl-util/run_postsubmit_tryjobs.go (about)

     1  // Copyright 2022 The Fuchsia Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"context"
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"log"
    14  	"net/url"
    15  	"os"
    16  	"slices"
    17  	"sort"
    18  	"strings"
    19  
    20  	"github.com/golang/protobuf/proto"
    21  	"github.com/maruel/subcommands"
    22  	"go.chromium.org/luci/auth"
    23  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    24  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    25  	cvpb "go.chromium.org/luci/cv/api/config/v2"
    26  	"go.fuchsia.dev/infra/buildbucket"
    27  	"go.fuchsia.dev/infra/flagutil"
    28  	"go.fuchsia.dev/infra/gerrit"
    29  	"go.fuchsia.dev/infra/gitiles"
    30  	"google.golang.org/genproto/protobuf/field_mask"
    31  	"google.golang.org/protobuf/encoding/protojson"
    32  	"google.golang.org/protobuf/types/known/structpb"
    33  )
    34  
    35  const rptLongDesc = `
    36  run-postsubmit-tryjobs run all available presubmit tryjobs for a CL that are
    37  required by postsubmit. Due to a number of reasons
    38  outlined in go/what-belongs-in-presubmit we can't run all tryjobs on all
    39  changes by default, so this tool is for allowing automated tooling or humans
    40  to specify which changes to run all builders for.
    41  `
    42  
    43  const buildbucketHost = "cr-buildbucket.appspot.com"
    44  
    45  func cmdRunPostsubmitTryjobs(authOpts auth.Options) *subcommands.Command {
    46  	return &subcommands.Command{
    47  		UsageLine: "run-postsubmit-tryjobs <CL URL>",
    48  		ShortDesc: "Trigger available tryjobs that are required for postsubmit for a CL.",
    49  		LongDesc:  rptLongDesc,
    50  		CommandRun: func() subcommands.CommandRun {
    51  			c := &rptCmd{}
    52  			c.Init(authOpts)
    53  			return c
    54  		},
    55  	}
    56  }
    57  
    58  type rptCmd struct {
    59  	commonFlags
    60  	gerritChangeID        int64
    61  	gerritPatchset        int64
    62  	jsonOutputFile        string
    63  	CommitQueueCfgPath    string
    64  	LUCIConfigHost        string
    65  	LUCIConfigPath        string
    66  	LUCIConfigProject     string
    67  	CrBuildBucketCfgPaths flagutil.RepeatedStringValue
    68  	dryRun                bool
    69  	force                 bool
    70  	allPresubmit          bool
    71  	verbose               bool
    72  }
    73  
    74  type BuildersToTrigger struct {
    75  	Builders []string `json:"builders"`
    76  }
    77  
    78  type LUCIConfigurationFiles struct {
    79  	commitQueueConfig    *cvpb.Config
    80  	crBuildBucketConfigs map[string]*buildbucketpb.BuildbucketCfg
    81  }
    82  
    83  func (c *rptCmd) Init(defaultAuthOpts auth.Options) {
    84  	c.commonFlags.Init(defaultAuthOpts)
    85  	c.Flags.Int64Var(
    86  		&c.gerritChangeID,
    87  		"gerrit-change-id",
    88  		0,
    89  		"The ChangeID of the GerritChange to trigger builders against.",
    90  	)
    91  	c.Flags.StringVar(
    92  		&c.LUCIConfigHost,
    93  		"luci-config-host",
    94  		"turquoise-internal.googlesource.com",
    95  		"Gerrit host for the project containing the LUCI configuration files.",
    96  	)
    97  	c.Flags.StringVar(
    98  		&c.LUCIConfigProject,
    99  		"luci-config-project",
   100  		"integration",
   101  		"Project name containing the LUCI configuration files.",
   102  	)
   103  	c.Flags.StringVar(
   104  		&c.CommitQueueCfgPath,
   105  		"commit-queue-cfg-path",
   106  		"infra/config/generated/turquoise/luci/commit-queue.cfg",
   107  		"Path to the LUCI commit-queue.cfg configuration file.",
   108  	)
   109  	c.Flags.Var(
   110  		&c.CrBuildBucketCfgPaths,
   111  		"cr-buildbucket-cfg-project-path",
   112  		"One or more comma-separated strings containing a project name and path to a LUCI cr-buildbucket.cfg configuration file. e.g. 'project,path/to/cr-buildbucket.cfg'",
   113  	)
   114  	c.Flags.BoolVar(
   115  		&c.allPresubmit,
   116  		"trigger-all-presubmit",
   117  		false,
   118  		"Whether to include presubmit builders not tagged for run-postsubmit-tryjobs. Projects outside Fuchsia that have not tagged their builders should enable this flag.",
   119  	)
   120  	c.Flags.BoolVar(
   121  		&c.force,
   122  		"f",
   123  		false,
   124  		"Whether to skip command line confirmation when triggering builders.",
   125  	)
   126  	c.Flags.BoolVar(
   127  		&c.verbose,
   128  		"v",
   129  		false,
   130  		"Whether to print all builders to be run.",
   131  	)
   132  	c.Flags.StringVar(
   133  		&c.jsonOutputFile,
   134  		"json-output",
   135  		"",
   136  		"Filepath to write json output to. Use '-' for stdout.",
   137  	)
   138  	c.Flags.BoolVar(
   139  		&c.dryRun,
   140  		"dry-run",
   141  		false,
   142  		"Whether to actually trigger the builders or just print out which builders would have been triggered.",
   143  	)
   144  }
   145  
   146  func (c *rptCmd) Parse(a subcommands.Application, args []string) error {
   147  	if len(args) != 0 && !strings.HasPrefix(args[0], "-") {
   148  		// The first positional argument is the changelink, parse it into constituent parts.
   149  		changeUrl, err := url.Parse(args[0])
   150  		if err != nil {
   151  			return err
   152  		}
   153  
   154  		gerritHost, changeID, err := gerrit.ResolveChangeUrl(changeUrl)
   155  		if err != nil {
   156  			return err
   157  		}
   158  		c.gerritHost = gerritHost
   159  		c.gerritChangeID = changeID
   160  	}
   161  
   162  	// Set placeholder value for gerritProject if it isn't set at this point.
   163  	// commonFlags.Parse includes validation for gerritProject to not be null,
   164  	// but we set it in getChangeDetails.
   165  	if c.gerritProject == "" {
   166  		c.gerritProject = "placeholder-project-name"
   167  	}
   168  
   169  	// Custom vars can't have defaults set so populate here in case it's unset.
   170  	if len(c.CrBuildBucketCfgPaths) == 0 {
   171  		c.CrBuildBucketCfgPaths = flagutil.RepeatedStringValue{
   172  			"turquoise,infra/config/generated/turquoise/luci/cr-buildbucket.cfg",
   173  			"fuchsia,infra/config/generated/fuchsia/luci/cr-buildbucket.cfg",
   174  		}
   175  	}
   176  
   177  	if err := c.commonFlags.Parse(); err != nil {
   178  		return err
   179  	}
   180  
   181  	return nil
   182  }
   183  
   184  func (c *rptCmd) Run(a subcommands.Application, args []string, _ subcommands.Env) int {
   185  	if err := c.Parse(a, args); err != nil {
   186  		fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
   187  		return 1
   188  	}
   189  
   190  	if err := c.main(); err != nil {
   191  		fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
   192  		return 1
   193  	}
   194  	return 0
   195  }
   196  
   197  func (c *rptCmd) main() error {
   198  	if !c.verbose {
   199  		log.SetOutput(io.Discard)
   200  	}
   201  
   202  	ctx := context.Background()
   203  
   204  	buildClient, err := buildbucket.NewBuildsClient(
   205  		ctx,
   206  		buildbucketHost,
   207  		c.commonFlags.parsedAuthOpts,
   208  	)
   209  	if err != nil {
   210  		return err
   211  	}
   212  
   213  	authClient, err := auth.NewAuthenticator(ctx, auth.OptionalLogin, c.commonFlags.parsedAuthOpts).Client()
   214  	if err != nil {
   215  		return fmt.Errorf("failed to get authenticated http client: %w", err)
   216  	}
   217  
   218  	gitilesClient, err := gitiles.NewClient(c.LUCIConfigHost, c.LUCIConfigProject, authClient)
   219  	if err != nil {
   220  		return err
   221  	}
   222  
   223  	lucicfg, err := c.getLUCIConfigs(ctx, *gitilesClient)
   224  	if err != nil {
   225  		return err
   226  	}
   227  
   228  	gerritClient, err := gerrit.NewClient(c.gerritHost, c.gerritProject, authClient)
   229  	if err != nil {
   230  		return err
   231  	}
   232  
   233  	if err = c.getChangeDetails(ctx, *gerritClient); err != nil {
   234  		return err
   235  	}
   236  
   237  	availablePresubmitBuilders := c.getAvailablePresubmitBuilders(lucicfg, c.gerritHost, c.gerritProject)
   238  
   239  	triggeredBuilds, err := c.getTriggeredBuildsForChange(ctx, buildClient)
   240  	if err != nil {
   241  		return err
   242  	}
   243  
   244  	triggeredBuilds = c.filterFailedBuilders(triggeredBuilds)
   245  	missingBuilders := c.getMissingBuilders(availablePresubmitBuilders, triggeredBuilds)
   246  	if err := c.writeJSONOutput(missingBuilders); err != nil {
   247  		return err
   248  	}
   249  	return c.triggerMissingBuilders(ctx, buildClient, missingBuilders)
   250  }
   251  
   252  // getLUCIConfigs fetches the LUCI configuration files contained at
   253  // the project specified in c.LUCIConfigProject and c.LUCIConfigPath
   254  func (c *rptCmd) getLUCIConfigs(ctx context.Context, gitilesClient gitiles.Client) (LUCIConfigurationFiles, error) {
   255  	log.Println("Downloading LUCI configuration files...")
   256  	// Set non-nil values for the configs.
   257  	commitQueueConfig := &cvpb.Config{}
   258  	crBuildBucketConfigs := map[string]*buildbucketpb.BuildbucketCfg{}
   259  
   260  	commitQueueFileContents, err := gitilesClient.DownloadFile(
   261  		ctx,
   262  		c.CommitQueueCfgPath,
   263  		"refs/heads/main",
   264  	)
   265  	if err != nil {
   266  		return LUCIConfigurationFiles{}, err
   267  	}
   268  
   269  	if err := proto.UnmarshalText(commitQueueFileContents, commitQueueConfig); err != nil {
   270  		return LUCIConfigurationFiles{}, err
   271  	}
   272  
   273  	for _, projectPath := range c.CrBuildBucketCfgPaths {
   274  		projectPathSplit := strings.Split(projectPath, ",")
   275  		if len(projectPathSplit) != 2 {
   276  			return LUCIConfigurationFiles{}, errors.New(
   277  				fmt.Sprintf(
   278  					"Invalid entry to cr-buildbucket-cfg-project-path, expected 'project,path/to/cr-buildbucket.cfg' got %s",
   279  					projectPath,
   280  				),
   281  			)
   282  		}
   283  		project := projectPathSplit[0]
   284  		path := projectPathSplit[1]
   285  
   286  		cfg := &buildbucketpb.BuildbucketCfg{}
   287  		CrBuildBucketFileContents, err := gitilesClient.DownloadFile(
   288  			ctx,
   289  			path,
   290  			"refs/heads/main",
   291  		)
   292  		if err != nil {
   293  			return LUCIConfigurationFiles{}, err
   294  		}
   295  
   296  		if err = proto.UnmarshalText(CrBuildBucketFileContents, cfg); err != nil {
   297  			return LUCIConfigurationFiles{}, err
   298  		}
   299  
   300  		crBuildBucketConfigs[project] = cfg
   301  	}
   302  
   303  	return LUCIConfigurationFiles{
   304  		commitQueueConfig:    commitQueueConfig,
   305  		crBuildBucketConfigs: crBuildBucketConfigs,
   306  	}, nil
   307  }
   308  
   309  // getChangeDetails queries Gerrit for the target change to determine
   310  // the ChangeRef, ChangeRepo, Project and Patchset (if not specified)
   311  func (c *rptCmd) getChangeDetails(ctx context.Context, gerritClient gerrit.Client) error {
   312  	change, err := gerritClient.GetChange(
   313  		ctx,
   314  		c.gerritChangeID,
   315  		gerritpb.QueryOption_ALL_REVISIONS,
   316  	)
   317  	if err != nil {
   318  		return err
   319  	}
   320  
   321  	c.gerritPatchset = int64(change.Revisions[change.CurrentRevision].Number)
   322  	c.gerritProject = change.Project
   323  
   324  	return nil
   325  }
   326  
   327  // getAvailablePresubmitBuilders parses through available builders in
   328  // presubmit and if an equivalent builder is required in postsubmit, adds it to
   329  // the list of builders we check to trigger.
   330  func (c *rptCmd) getAvailablePresubmitBuilders(lucicfg LUCIConfigurationFiles, gerritHost string, gerritProject string) []*buildbucketpb.BuilderID {
   331  	// Throw buckets into a map for cross referencing.
   332  	flaggedBuildersByBucketAndProjectMap := map[string]map[string]map[string]bool{}
   333  	for project, cfg := range lucicfg.crBuildBucketConfigs {
   334  		if _, ok := flaggedBuildersByBucketAndProjectMap[project]; !ok {
   335  			flaggedBuildersByBucketAndProjectMap[project] = map[string]map[string]bool{}
   336  		}
   337  		for _, bucket := range cfg.GetBuckets() {
   338  			flaggedBuildersByBucketAndProjectMap[project][bucket.GetName()] = getBuildersFlaggedForRpt(bucket)
   339  		}
   340  	}
   341  
   342  	// Find CQ for the target change.
   343  	configGroups := lucicfg.commitQueueConfig.GetConfigGroups()
   344  	var availablePresubmitBuilders []*buildbucketpb.BuilderID
   345  
   346  	for _, cfg := range configGroups {
   347  		projectMatch := cfg.GetGerrit()[0].GetProjects()[0].GetName() == gerritProject
   348  		cfgUrl, _ := url.Parse(cfg.GetGerrit()[0].GetUrl())
   349  		hostMatch := cfgUrl.Host == gerritHost
   350  		if projectMatch && hostMatch {
   351  			for _, builder := range cfg.GetVerifiers().GetTryjob().GetBuilders() {
   352  				builderSplit := strings.Split(builder.GetName(), "/")
   353  				builderID := &buildbucketpb.BuilderID{
   354  					Project: builderSplit[0],
   355  					Bucket:  builderSplit[1],
   356  					Builder: builderSplit[2],
   357  				}
   358  
   359  				if c.allPresubmit || flaggedBuildersByBucketAndProjectMap[builderID.Project][builderID.Bucket][builderID.Builder] {
   360  					availablePresubmitBuilders = append(availablePresubmitBuilders, builderID)
   361  				}
   362  			}
   363  		}
   364  	}
   365  
   366  	// Sort for logging output clarity.
   367  	sort.SliceStable(availablePresubmitBuilders, func(i, j int) bool {
   368  		return availablePresubmitBuilders[i].Builder < availablePresubmitBuilders[j].Builder
   369  	})
   370  
   371  	return availablePresubmitBuilders
   372  }
   373  
   374  // getTriggeredBuildersForChange calls the buildbucket SearchBuilds RPC
   375  // and reports back all builders that have already triggered against the change.
   376  func (c *rptCmd) getTriggeredBuildsForChange(ctx context.Context, buildClient buildbucketpb.BuildsClient) ([]*buildbucketpb.Build, error) {
   377  	// Pull all builds triggered against the CL.
   378  	resp, err := buildClient.SearchBuilds(
   379  		ctx,
   380  		&buildbucketpb.SearchBuildsRequest{
   381  			Predicate: &buildbucketpb.BuildPredicate{
   382  				GerritChanges: []*buildbucketpb.GerritChange{
   383  					{
   384  						Host:     c.gerritHost,
   385  						Project:  c.gerritProject,
   386  						Change:   c.gerritChangeID,
   387  						Patchset: c.gerritPatchset,
   388  					},
   389  				},
   390  			},
   391  			Fields: &field_mask.FieldMask{Paths: []string{
   392  				"builds.*.builder",
   393  				"builds.*.status",
   394  			}},
   395  			PageSize: 1000,
   396  		},
   397  	)
   398  	if err != nil {
   399  		return []*buildbucketpb.Build{}, err
   400  	}
   401  
   402  	return resp.GetBuilds(), nil
   403  }
   404  
   405  // filterFailedBuilders filters failed builders out of the triggered builders
   406  // so that they will be re-triggered by run-postsubmit-tryjobs.
   407  func (c *rptCmd) filterFailedBuilders(triggeredBuilds []*buildbucketpb.Build) []*buildbucketpb.Build {
   408  	var filteredBuilds []*buildbucketpb.Build
   409  	for _, build := range triggeredBuilds {
   410  		if !slices.Contains(
   411  			[]buildbucketpb.Status{
   412  				buildbucketpb.Status_FAILURE,
   413  				buildbucketpb.Status_INFRA_FAILURE,
   414  			},
   415  			build.GetStatus(),
   416  		) {
   417  			filteredBuilds = append(filteredBuilds, build)
   418  		}
   419  	}
   420  	return filteredBuilds
   421  }
   422  
   423  // getMissingBuilders finds the diff between availablePresubmitBuilders and the
   424  // builds already triggered by the change.
   425  func (c *rptCmd) getMissingBuilders(availablePresubmitBuilders []*buildbucketpb.BuilderID, triggeredPresubmitBuilds []*buildbucketpb.Build) []*buildbucketpb.BuilderID {
   426  	var missingBuilders []*buildbucketpb.BuilderID
   427  	var triggeredBuilders []string
   428  	for _, build := range triggeredPresubmitBuilds {
   429  		triggeredBuilders = append(triggeredBuilders, build.GetBuilder().GetBuilder())
   430  	}
   431  
   432  	// Find which builders haven't been triggered yet.
   433  	for _, builder := range availablePresubmitBuilders {
   434  		if !slices.Contains(triggeredBuilders, builder.GetBuilder()) && !builderSliceContains(missingBuilders, builder) {
   435  			missingBuilders = append(missingBuilders, builder)
   436  		}
   437  	}
   438  
   439  	return missingBuilders
   440  }
   441  
   442  // triggerMissingBuilders triggers the diff between all builders available
   443  // and the builders that have already been triggered for the change.
   444  func (c *rptCmd) triggerMissingBuilders(ctx context.Context, buildClient buildbucketpb.BuildsClient, missingBuilders []*buildbucketpb.BuilderID) error {
   445  	// If we have no builders to trigger, return.
   446  	if len(missingBuilders) == 0 {
   447  		log.Println("No builders found to trigger.")
   448  		return nil
   449  	}
   450  
   451  	// Print out list of builders to be triggered for logging.
   452  	for _, builder := range missingBuilders {
   453  		log.Printf("Builder: %s\n", builder.GetBuilder())
   454  	}
   455  
   456  	// Require command line confirmation.  Automated tools should pass the -f flag.
   457  	if !(c.force || c.dryRun) {
   458  		// The cost has been calculated roughly on average as of 2022 to be $0.57 per builder triggered
   459  		// GCE Cost: $0.53/h
   460  		// Orchestrator builder: 0.45h
   461  		// Subbuild builder: 0.45h
   462  		// Testing Subtasks: 0.17h
   463  		fmt.Printf("You are about to trigger %d tryjobs with an estimated cost of $%.2f USD. Additionally, this will likely consume inelastic resources. Do you wish to proceed? (y/n)\n", len(missingBuilders), 0.57*float64(len(missingBuilders)))
   464  		confirm := ""
   465  		fmt.Scanln(&confirm)
   466  		if !slices.Contains([]string{"y", "Y", "yes", "Yes", "YES"}, confirm) {
   467  			fmt.Println("Process aborted.")
   468  			return nil
   469  		} else {
   470  			fmt.Println("You can skip this confirmation next time by passing the -f flag.")
   471  		}
   472  	}
   473  
   474  	// Assemble requests into a batch.
   475  	var requestBatch []*buildbucketpb.BatchRequest_Request
   476  	for _, builder := range missingBuilders {
   477  		req := &buildbucketpb.BatchRequest_Request{
   478  			Request: &buildbucketpb.BatchRequest_Request_ScheduleBuild{
   479  				ScheduleBuild: &buildbucketpb.ScheduleBuildRequest{
   480  					Builder: builder,
   481  					GerritChanges: []*buildbucketpb.GerritChange{
   482  						{
   483  							Host:     c.gerritHost,
   484  							Project:  c.gerritProject,
   485  							Change:   c.gerritChangeID,
   486  							Patchset: c.gerritPatchset,
   487  						},
   488  					},
   489  					// Slightly below default priority.
   490  					Priority: 31,
   491  				},
   492  			},
   493  		}
   494  		requestBatch = append(requestBatch, req)
   495  	}
   496  
   497  	if !c.dryRun {
   498  		resp, err := buildClient.Batch(
   499  			ctx,
   500  			&buildbucketpb.BatchRequest{
   501  				Requests: requestBatch,
   502  			},
   503  		)
   504  		if err != nil {
   505  			return err
   506  		}
   507  
   508  		jsonResp, err := json.MarshalIndent(resp, "", "  ")
   509  		if err != nil {
   510  			return err
   511  		}
   512  
   513  		log.Printf(
   514  			"BatchRequest response: \n%s",
   515  			jsonResp,
   516  		)
   517  	}
   518  
   519  	return nil
   520  }
   521  
   522  func (c *rptCmd) writeJSONOutput(builders []*buildbucketpb.BuilderID) error {
   523  	if c.jsonOutputFile == "" {
   524  		return nil
   525  	}
   526  	// Assemble builder data into easy to consume json output.
   527  	jsonOutput := &BuildersToTrigger{Builders: []string{}}
   528  	for _, b := range builders {
   529  		jsonOutput.Builders = append(jsonOutput.Builders, b.Builder)
   530  	}
   531  
   532  	// Marshal output to json.
   533  	var rawJSON []byte
   534  	var err error
   535  	var f io.WriteCloser = os.Stdout
   536  	if c.jsonOutputFile != "-" {
   537  		if f, err = os.Create(c.jsonOutputFile); err != nil {
   538  			return err
   539  		}
   540  		defer f.Close()
   541  	}
   542  
   543  	if c.jsonOutputFile == "-" {
   544  		rawJSON, err = json.MarshalIndent(jsonOutput, "", "    ")
   545  		rawJSON = append(rawJSON, '\n')
   546  	} else {
   547  		rawJSON, err = json.Marshal(jsonOutput)
   548  		rawJSON = append(rawJSON, '\n')
   549  	}
   550  	if err != nil {
   551  		return err
   552  	}
   553  
   554  	_, err = f.Write(rawJSON)
   555  	return err
   556  }
   557  
   558  // slices.Contains doesn't really work with slices of pointers, so this helper
   559  // performs a low effort check for inclusion.
   560  func builderSliceContains(slice []*buildbucketpb.BuilderID, builder *buildbucketpb.BuilderID) bool {
   561  	for _, b := range slice {
   562  		if b.GetBuilder() == builder.GetBuilder() {
   563  			return true
   564  		}
   565  	}
   566  	return false
   567  }
   568  
   569  // getBuildersFlaggedForRpt finds builders with the `run_postsubmit_tryjobs_include` property.
   570  func getBuildersFlaggedForRpt(bucket *buildbucketpb.Bucket) map[string]bool {
   571  	flaggedBuilders := map[string]bool{}
   572  	for _, b := range bucket.GetSwarming().GetBuilders() {
   573  		properties := &structpb.Struct{}
   574  		protojson.Unmarshal([]byte(b.GetProperties()), properties)
   575  		if builderTags, ok := properties.GetFields()["$fuchsia/builder_tags"]; ok {
   576  			if runPostsubmitTryjobsInclude, ok := builderTags.GetStructValue().GetFields()["run_postsubmit_tryjobs_include"]; ok {
   577  				flaggedBuilders[b.GetName()] = runPostsubmitTryjobsInclude.GetBoolValue()
   578  			} else {
   579  				flaggedBuilders[b.GetName()] = false
   580  			}
   581  		} else {
   582  			flaggedBuilders[b.GetName()] = false
   583  		}
   584  	}
   585  
   586  	return flaggedBuilders
   587  }