go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/lucicfg/cli/cmds/validate/validate.go (about)

     1  // Copyright 2018 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 validate implements 'validate' subcommand.
    16  package validate
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"os"
    22  	"path/filepath"
    23  	"sort"
    24  	"strings"
    25  
    26  	"github.com/bazelbuild/buildtools/build"
    27  	"github.com/maruel/subcommands"
    28  
    29  	"go.chromium.org/luci/common/cli"
    30  
    31  	"go.chromium.org/luci/lucicfg"
    32  	"go.chromium.org/luci/lucicfg/buildifier"
    33  	"go.chromium.org/luci/lucicfg/cli/base"
    34  )
    35  
    36  // Cmd is 'validate' subcommand.
    37  func Cmd(params base.Parameters) *subcommands.Command {
    38  	return &subcommands.Command{
    39  		UsageLine: "validate [CONFIG_DIR|SCRIPT]",
    40  		ShortDesc: "sends configuration files to LUCI Config service for validation",
    41  		LongDesc: `Sends configuration files to LUCI Config service for validation.
    42  
    43  If the first positional argument is a directory, takes all files there and
    44  sends them to LUCI Config service, to be validated as a single config set. The
    45  name of the config set (e.g. "projects/foo") MUST be provided via -config-set
    46  flag, it is required in this mode.
    47  
    48  If the first positional argument is a Starlark file, it is interpreted (as with
    49  'generate' subcommand) and the resulting generated configs are compared to
    50  what's already on disk in -config-dir directory.
    51  
    52  By default uses semantic comparison (i.e. config files on disk are deserialized
    53  and compared to the generated files as objects). This is useful to ignore
    54  insignificant formatting changes that may appear due to differences between
    55  lucicfg versions. If -strict is used, compares files as byte blobs. In this case
    56  'validate' detects no changes if and only if 'generate' produces no diff.
    57  
    58  If configs on disk are different from the generated ones, the subcommand exits
    59  with non-zero exit code. Otherwise the configs are sent to LUCI Config service
    60  for validation. Partitioning into config sets is specified in the Starlark code
    61  in this case, -config-set flag is rejected if given.
    62  
    63  When interpreting Starlark script, flags like -config-dir and -fail-on-warnings
    64  work as overrides for values declared in the script via lucicfg.config(...)
    65  statement. See its doc for more details.
    66  		`,
    67  		CommandRun: func() subcommands.CommandRun {
    68  			vr := &validateRun{}
    69  			vr.Init(params)
    70  			vr.AddGeneratorFlags()
    71  			vr.Flags.StringVar(&vr.configSet, "config-set", "",
    72  				"Name of the config set to validate against when validating existing *.cfg configs.")
    73  			vr.Flags.BoolVar(&vr.strict, "strict", false,
    74  				"Use byte-by-byte comparison instead of comparing configs as proto messages.")
    75  			return vr
    76  		},
    77  	}
    78  }
    79  
    80  type validateRun struct {
    81  	base.Subcommand
    82  
    83  	configSet string // used only when validating existing *.cfg
    84  	strict    bool   // -strict flag
    85  }
    86  
    87  type validateResult struct {
    88  	// Meta is the final meta parameters used by the generator.
    89  	Meta *lucicfg.Meta `json:"meta,omitempty"`
    90  	// LinterFindings is linter findings (if enabled).
    91  	LinterFindings []*buildifier.Finding `json:"linter_findings,omitempty"`
    92  	// Validation is per config set validation results.
    93  	Validation []*lucicfg.ValidationResult `json:"validation"`
    94  
    95  	// Stale is a list of config files on disk that are out-of-date compared to
    96  	// what is produced by the starlark script.
    97  	//
    98  	// When non-empty, means invocation of "lucicfg generate ..." will either
    99  	// update or delete all these files.
   100  	Stale []string `json:"stale,omitempty"`
   101  }
   102  
   103  func (vr *validateRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
   104  	if !vr.CheckArgs(args, 1, 1) {
   105  		return 1
   106  	}
   107  
   108  	// The first argument is either a directory with a config set to validate,
   109  	// or a entry point *.star file.
   110  	target := args[0]
   111  
   112  	// If 'target' is a directory, it is a directory with generated files we
   113  	// need to validate. If it is a file, it is *.star file to use to generate
   114  	// configs in memory and compare them to whatever is on disk.
   115  	ctx := cli.GetContext(a, vr, env)
   116  	switch fi, err := os.Stat(target); {
   117  	case os.IsNotExist(err):
   118  		return vr.Done(nil, fmt.Errorf("no such file: %s", target))
   119  	case err != nil:
   120  		return vr.Done(nil, err)
   121  	case fi.Mode().IsDir():
   122  		return vr.Done(vr.validateExisting(ctx, target))
   123  	default:
   124  		return vr.Done(vr.validateGenerated(ctx, target))
   125  	}
   126  }
   127  
   128  // validateExisting validates an existing config set on disk, whatever it may
   129  // be.
   130  //
   131  // Verifies -config-set flag was used, since it is the only way to provide the
   132  // name of the config set to verify against in this case.
   133  //
   134  // Also verifies -config-dir is NOT used, since it is redundant: the directory
   135  // is passed through the positional arguments.
   136  func (vr *validateRun) validateExisting(ctx context.Context, dir string) (*validateResult, error) {
   137  	switch {
   138  	case vr.configSet == "":
   139  		return nil, base.MissingFlagError("-config-set")
   140  	case vr.Meta.ConfigServiceHost == "":
   141  		return nil, base.MissingFlagError("-config-service-host")
   142  	case vr.Meta.WasTouched("config_dir"):
   143  		return nil, base.NewCLIError("-config-dir shouldn't be used, the directory was already given as positional argument")
   144  	}
   145  	configSet, err := lucicfg.ReadConfigSet(dir, vr.configSet)
   146  	if err != nil {
   147  		return nil, err
   148  	}
   149  	_, res, err := base.Validate(ctx, base.ValidateParams{
   150  		Output:                    configSet.AsOutput("."),
   151  		Meta:                      vr.Meta,
   152  		LegacyConfigServiceClient: vr.LegacyConfigServiceClient,
   153  		ConfigServiceConn:         vr.MakeConfigServiceConn,
   154  	}, nil)
   155  	return &validateResult{Validation: res}, err
   156  }
   157  
   158  // validateGenerated executes Starlark script, compares the result to whatever
   159  // is on disk (failing the validation if there's a difference), and then sends
   160  // the output to LUCI Config service for validation.
   161  func (vr *validateRun) validateGenerated(ctx context.Context, path string) (*validateResult, error) {
   162  	// -config-set flag must not be used in this mode, config sets are defined
   163  	// on Starlark level.
   164  	if vr.configSet != "" {
   165  		return nil, base.NewCLIError("-config-set can't be used when validating Starlark-based configs")
   166  	}
   167  
   168  	meta := vr.DefaultMeta()
   169  	state, err := base.GenerateConfigs(ctx, path, &meta, &vr.Meta, vr.Vars)
   170  	if err != nil {
   171  		return nil, err
   172  	}
   173  	output := state.Output
   174  
   175  	result := &validateResult{Meta: &meta}
   176  
   177  	if meta.ConfigDir != "-" {
   178  		// Find files that are present on disk, but no longer in the output.
   179  		tracked, err := lucicfg.FindTrackedFiles(meta.ConfigDir, meta.TrackedFiles)
   180  		if err != nil {
   181  			return result, err
   182  		}
   183  		for _, f := range tracked {
   184  			if _, present := output.Data[f]; !present {
   185  				result.Stale = append(result.Stale, f)
   186  			}
   187  		}
   188  
   189  		// Find files that are newer in the output or do not exist on disk. Do
   190  		// semantic comparison for protos, unless -strict is set.
   191  		cmp, err := output.Compare(meta.ConfigDir, !vr.strict)
   192  		if err != nil {
   193  			return result, err
   194  		}
   195  		for name, res := range cmp {
   196  			if res == lucicfg.Different {
   197  				result.Stale = append(result.Stale, name)
   198  			}
   199  		}
   200  		sort.Strings(result.Stale)
   201  
   202  		// Ask the user to regenerate files if they are different.
   203  		if len(result.Stale) != 0 {
   204  			return result, fmt.Errorf(
   205  				"the following files need to be regenerated: %s.\n"+
   206  					"  Run `lucicfg generate %q` to update them.",
   207  				strings.Join(result.Stale, ", "), path)
   208  		}
   209  
   210  		// We want to make sure the *exact* files we have on disk pass the server
   211  		// validation (even if they are, perhaps, semantically identical to files in
   212  		// 'output', as we have just checked). Replace the generated output with
   213  		// what's on disk.
   214  		if err := output.Read(meta.ConfigDir); err != nil {
   215  			return result, err
   216  		}
   217  	}
   218  
   219  	entryPath, err := filepath.Abs(filepath.Dir(path))
   220  	if err != nil {
   221  		return nil, err
   222  	}
   223  
   224  	if err := base.CheckForBogusConfig(entryPath); err != nil {
   225  		return nil, err
   226  	}
   227  
   228  	rewriterFactory, err := base.GetRewriterFactory(filepath.Join(entryPath, base.ConfigName))
   229  	if err != nil {
   230  		return nil, err
   231  	}
   232  
   233  	// Apply local linters and validate outputs via LUCI Config RPC. This silently
   234  	// skips configs not belonging to any config sets.
   235  	result.LinterFindings, result.Validation, err = base.Validate(ctx, base.ValidateParams{
   236  		Loader:                    state.Inputs.Code,
   237  		Source:                    state.Visited,
   238  		Output:                    output,
   239  		Meta:                      meta,
   240  		LegacyConfigServiceClient: vr.LegacyConfigServiceClient,
   241  		ConfigServiceConn:         vr.MakeConfigServiceConn,
   242  	}, func(path string) (*build.Rewriter, error) {
   243  		// GetRewriter needs to see absolute paths; In Validate the paths are all
   244  		// relative to the entrypoint (e.g. main.star) becuase they refer to
   245  		// Starlark module import paths.
   246  		//
   247  		// Adjusting state.Visited above will fail because part of Validate's
   248  		// functionality needs to retain these relative paths.
   249  		return rewriterFactory.GetRewriter(filepath.Join(entryPath, path))
   250  	})
   251  	return result, err
   252  }