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 }