github.com/jfrog/jfrog-cli-core@v1.12.1/artifactory/commands/npm/installorci.go (about) 1 package npm 2 3 import ( 4 "bufio" 5 "errors" 6 "fmt" 7 commandUtils "github.com/jfrog/jfrog-cli-core/artifactory/commands/utils" 8 npmutils "github.com/jfrog/jfrog-cli-core/utils/npm" 9 "os" 10 "os/exec" 11 "path/filepath" 12 "strconv" 13 "strings" 14 15 "github.com/buger/jsonparser" 16 gofrogcmd "github.com/jfrog/gofrog/io" 17 "github.com/jfrog/gofrog/parallel" 18 "github.com/jfrog/jfrog-cli-core/artifactory/utils" 19 "github.com/jfrog/jfrog-cli-core/artifactory/utils/npm" 20 "github.com/jfrog/jfrog-cli-core/utils/config" 21 "github.com/jfrog/jfrog-client-go/artifactory" 22 "github.com/jfrog/jfrog-client-go/artifactory/buildinfo" 23 "github.com/jfrog/jfrog-client-go/auth" 24 clientutils "github.com/jfrog/jfrog-client-go/utils" 25 "github.com/jfrog/jfrog-client-go/utils/errorutils" 26 "github.com/jfrog/jfrog-client-go/utils/log" 27 "github.com/jfrog/jfrog-client-go/utils/version" 28 ) 29 30 const npmrcFileName = ".npmrc" 31 const npmrcBackupFileName = "jfrog.npmrc.backup" 32 const minSupportedNpmVersion = "5.4.0" 33 34 type NpmCommandArgs struct { 35 command string 36 threads int 37 jsonOutput bool 38 executablePath string 39 restoreNpmrcFunc func() error 40 workingDirectory string 41 registry string 42 npmAuth string 43 collectBuildInfo bool 44 dependencies map[string]*dependency 45 typeRestriction typeRestriction 46 authArtDetails auth.ServiceDetails 47 packageInfo *npmutils.PackageInfo 48 npmVersion *version.Version 49 NpmCommand 50 } 51 52 type typeRestriction int 53 54 const ( 55 defaultRestriction typeRestriction = iota 56 all 57 devOnly 58 prodOnly 59 ) 60 61 type NpmInstallOrCiCommand struct { 62 configFilePath string 63 internalCommandName string 64 *NpmCommandArgs 65 } 66 67 func NewNpmInstallCommand() *NpmInstallOrCiCommand { 68 return &NpmInstallOrCiCommand{NpmCommandArgs: NewNpmCommandArgs("install"), internalCommandName: "rt_npm_install"} 69 } 70 71 func NewNpmCiCommand() *NpmInstallOrCiCommand { 72 return &NpmInstallOrCiCommand{NpmCommandArgs: NewNpmCommandArgs("ci"), internalCommandName: "rt_npm_ci"} 73 } 74 75 func (nic *NpmInstallOrCiCommand) CommandName() string { 76 return nic.internalCommandName 77 } 78 79 func (nic *NpmInstallOrCiCommand) SetConfigFilePath(configFilePath string) *NpmInstallOrCiCommand { 80 nic.configFilePath = configFilePath 81 return nic 82 } 83 84 func (nic *NpmInstallOrCiCommand) SetArgs(args []string) *NpmInstallOrCiCommand { 85 nic.NpmCommandArgs.npmArgs = args 86 return nic 87 } 88 89 func (nic *NpmInstallOrCiCommand) SetRepoConfig(conf *utils.RepositoryConfig) *NpmInstallOrCiCommand { 90 serverDetails, _ := conf.ServerDetails() 91 nic.NpmCommandArgs.SetRepo(conf.TargetRepo()).SetServerDetails(serverDetails) 92 return nic 93 } 94 95 func (nic *NpmInstallOrCiCommand) Run() error { 96 log.Info(fmt.Sprintf("Running npm %s.", nic.command)) 97 // Read config file. 98 log.Debug("Preparing to read the config file", nic.configFilePath) 99 vConfig, err := utils.ReadConfigFile(nic.configFilePath, utils.YAML) 100 if err != nil { 101 return err 102 } 103 // Extract resolution params. 104 resolverParams, err := utils.GetRepoConfigByPrefix(nic.configFilePath, utils.ProjectConfigResolverPrefix, vConfig) 105 if err != nil { 106 return err 107 } 108 threads, _, filteredNpmArgs, buildConfiguration, err := commandUtils.ExtractNpmOptionsFromArgs(nic.npmArgs) 109 if err != nil { 110 return err 111 } 112 nic.SetRepoConfig(resolverParams).SetArgs(filteredNpmArgs).SetThreads(threads).SetBuildConfiguration(buildConfiguration) 113 return nic.run() 114 } 115 116 func (nca *NpmCommandArgs) SetThreads(threads int) *NpmCommandArgs { 117 nca.threads = threads 118 return nca 119 } 120 121 func NewNpmCommandArgs(npmCommand string) *NpmCommandArgs { 122 return &NpmCommandArgs{command: npmCommand} 123 } 124 125 func (nca *NpmCommandArgs) ServerDetails() (*config.ServerDetails, error) { 126 return nca.serverDetails, nil 127 } 128 129 func (nca *NpmCommandArgs) run() error { 130 if err := nca.preparePrerequisites(nca.repo); err != nil { 131 return err 132 } 133 134 if err := nca.createTempNpmrc(); err != nil { 135 return nca.restoreNpmrcAndError(err) 136 } 137 138 if err := nca.runInstallOrCi(); err != nil { 139 return nca.restoreNpmrcAndError(err) 140 } 141 142 if err := nca.restoreNpmrcFunc(); err != nil { 143 return err 144 } 145 146 if !nca.collectBuildInfo { 147 log.Info(fmt.Sprintf("npm %s finished successfully.", nca.command)) 148 return nil 149 } 150 151 if err := nca.setDependenciesList(); err != nil { 152 return err 153 } 154 155 if err := nca.collectDependenciesChecksums(); err != nil { 156 return err 157 } 158 159 if err := nca.saveDependenciesData(); err != nil { 160 return err 161 } 162 163 log.Info(fmt.Sprintf("npm %s finished successfully.", nca.command)) 164 return nil 165 } 166 167 func (nca *NpmCommandArgs) preparePrerequisites(repo string) error { 168 log.Debug("Preparing prerequisites.") 169 var err error 170 if err = nca.setNpmExecutable(); err != nil { 171 return err 172 } 173 174 if err = nca.validateNpmVersion(); err != nil { 175 return err 176 } 177 178 if err := nca.setJsonOutput(); err != nil { 179 return err 180 } 181 182 nca.workingDirectory, err = commandUtils.GetWorkingDirectory() 183 if err != nil { 184 return err 185 } 186 log.Debug("Working directory set to:", nca.workingDirectory) 187 188 if err = nca.setArtifactoryAuth(); err != nil { 189 return err 190 } 191 192 nca.npmAuth, nca.registry, err = commandUtils.GetArtifactoryNpmRepoDetails(repo, &nca.authArtDetails) 193 if err != nil { 194 return err 195 } 196 197 nca.collectBuildInfo, nca.packageInfo, err = commandUtils.PrepareBuildInfo(nca.workingDirectory, nca.buildConfiguration, nca.npmVersion) 198 if err != nil { 199 return err 200 } 201 202 nca.restoreNpmrcFunc, err = commandUtils.BackupFile(filepath.Join(nca.workingDirectory, npmrcFileName), filepath.Join(nca.workingDirectory, npmrcBackupFileName)) 203 return err 204 } 205 206 func (nca *NpmCommandArgs) setJsonOutput() error { 207 jsonOutput, err := npm.ConfigGet(nca.npmArgs, "json", nca.executablePath) 208 if err != nil { 209 return err 210 } 211 212 // In case of --json=<not boolean>, the value of json is set to 'true', but the result from the command is not 'true' 213 nca.jsonOutput = jsonOutput != "false" 214 return nil 215 } 216 217 func createRestoreErrorPrefix(workingDirectory string) string { 218 return fmt.Sprintf("Error occurred while restoring project .npmrc file. "+ 219 "Delete '%s' and move '%s' (if exists) to '%s' in order to restore the project. Failure cause: \n", 220 filepath.Join(workingDirectory, npmrcFileName), 221 filepath.Join(workingDirectory, npmrcBackupFileName), 222 filepath.Join(workingDirectory, npmrcFileName)) 223 } 224 225 // In order to make sure the install/ci downloads the artifacts from Artifactory we create a .npmrc file in the project dir. 226 // If such a file exists we back it up as npmrcBackupFileName. 227 func (nca *NpmCommandArgs) createTempNpmrc() error { 228 log.Debug("Creating project .npmrc file.") 229 data, err := npm.GetConfigList(nca.npmArgs, nca.executablePath) 230 configData, err := nca.prepareConfigData(data) 231 if err != nil { 232 return errorutils.CheckError(err) 233 } 234 235 if err = removeNpmrcIfExists(nca.workingDirectory); err != nil { 236 return err 237 } 238 239 return errorutils.CheckError(os.WriteFile(filepath.Join(nca.workingDirectory, npmrcFileName), configData, 0600)) 240 } 241 242 func (nca *NpmCommandArgs) runInstallOrCi() error { 243 log.Debug(fmt.Sprintf("Running npm %s command.", nca.command)) 244 filteredArgs := filterFlags(nca.npmArgs) 245 npmCmdConfig := &npm.NpmConfig{ 246 Npm: nca.executablePath, 247 Command: append([]string{nca.command}, filteredArgs...), 248 CommandFlags: nil, 249 StrWriter: nil, 250 ErrWriter: nil, 251 } 252 253 if nca.collectBuildInfo && len(filteredArgs) > 0 { 254 log.Warn("Build info dependencies collection with npm arguments is not supported. Build info creation will be skipped.") 255 nca.collectBuildInfo = false 256 } 257 258 return errorutils.CheckError(gofrogcmd.RunCmd(npmCmdConfig)) 259 } 260 261 func (nca *NpmCommandArgs) setDependenciesList() (err error) { 262 nca.dependencies = make(map[string]*dependency) 263 // nca.typeRestriction default is 'all' 264 if nca.typeRestriction != prodOnly { 265 if err = nca.prepareDependencies("dev"); err != nil { 266 return 267 } 268 } 269 if nca.typeRestriction != devOnly { 270 err = nca.prepareDependencies("prod") 271 } 272 return 273 } 274 275 func (nca *NpmCommandArgs) collectDependenciesChecksums() error { 276 log.Info("Collecting dependencies information... For the first run of the build, this may take a few minutes. Subsequent runs should be faster.") 277 servicesManager, err := utils.CreateServiceManager(nca.serverDetails, -1, false) 278 if err != nil { 279 return err 280 } 281 282 previousBuildDependencies, err := commandUtils.GetDependenciesFromLatestBuild(servicesManager, nca.buildConfiguration.BuildName) 283 if err != nil { 284 return err 285 } 286 producerConsumer := parallel.NewBounedRunner(nca.threads, false) 287 errorsQueue := clientutils.NewErrorsQueue(1) 288 handlerFunc := nca.createGetDependencyInfoFunc(servicesManager, previousBuildDependencies) 289 go func() { 290 defer producerConsumer.Done() 291 for i := range nca.dependencies { 292 producerConsumer.AddTaskWithError(handlerFunc(i), errorsQueue.AddError) 293 } 294 }() 295 producerConsumer.Run() 296 return errorsQueue.GetError() 297 } 298 299 func (nca *NpmCommandArgs) saveDependenciesData() error { 300 log.Debug("Saving data.") 301 if nca.buildConfiguration.Module == "" { 302 nca.buildConfiguration.Module = nca.packageInfo.BuildInfoModuleId() 303 } 304 305 dependencies, missingDependencies := nca.transformDependencies() 306 if err := commandUtils.SaveDependenciesData(dependencies, nca.buildConfiguration); err != nil { 307 return err 308 } 309 310 commandUtils.PrintMissingDependencies(missingDependencies) 311 return nil 312 } 313 314 func (nca *NpmCommandArgs) validateNpmVersion() error { 315 npmVersion, err := npmutils.Version(nca.executablePath) 316 if err != nil { 317 return err 318 } 319 if npmVersion.Compare(minSupportedNpmVersion) > 0 { 320 return errorutils.CheckError(errors.New(fmt.Sprintf( 321 "JFrog CLI npm %s command requires npm client version "+minSupportedNpmVersion+" or higher", nca.command))) 322 } 323 nca.npmVersion = npmVersion 324 return nil 325 } 326 327 // This func transforms "npm config list" result to key=val list of values that can be set to .npmrc file. 328 // it filters any nil values key, changes registry and scope registries to Artifactory url and adds Artifactory authentication to the list 329 func (nca *NpmCommandArgs) prepareConfigData(data []byte) ([]byte, error) { 330 var filteredConf []string 331 configString := string(data) 332 scanner := bufio.NewScanner(strings.NewReader(configString)) 333 334 for scanner.Scan() { 335 currOption := scanner.Text() 336 if currOption != "" { 337 splitOption := strings.SplitN(currOption, "=", 2) 338 key := strings.TrimSpace(splitOption[0]) 339 if len(splitOption) == 2 && isValidKey(key) { 340 value := strings.TrimSpace(splitOption[1]) 341 if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { 342 filteredConf = addArrayConfigs(filteredConf, key, value) 343 } else { 344 filteredConf = append(filteredConf, currOption, "\n") 345 } 346 nca.setTypeRestriction(key, value) 347 } else if strings.HasPrefix(splitOption[0], "@") { 348 // Override scoped registries (@scope = xyz) 349 filteredConf = append(filteredConf, splitOption[0], " = ", nca.registry, "\n") 350 } 351 } 352 } 353 if err := scanner.Err(); err != nil { 354 return nil, errorutils.CheckError(err) 355 } 356 357 filteredConf = append(filteredConf, "json = ", strconv.FormatBool(nca.jsonOutput), "\n") 358 filteredConf = append(filteredConf, "registry = ", nca.registry, "\n") 359 filteredConf = append(filteredConf, nca.npmAuth) 360 return []byte(strings.Join(filteredConf, "")), nil 361 } 362 363 // Gets a config with value which is an array, and adds it to the conf list 364 func addArrayConfigs(conf []string, key, arrayValue string) []string { 365 if arrayValue == "[]" { 366 return conf 367 } 368 369 values := strings.TrimPrefix(strings.TrimSuffix(arrayValue, "]"), "[") 370 valuesSlice := strings.Split(values, ",") 371 for _, val := range valuesSlice { 372 confToAdd := fmt.Sprintf("%s[] = %s", key, val) 373 conf = append(conf, confToAdd, "\n") 374 } 375 376 return conf 377 } 378 379 func (nca *NpmCommandArgs) setTypeRestriction(key string, value string) { 380 // From npm 7, type restriction is determined by 'omit' and 'include' (both appear in 'npm config ls'). 381 // Other options (like 'dev', 'production' and 'only') are deprecated, but if they're used anyway - 'omit' and 'include' are automatically calculated. 382 // So 'omit' is always preferred, if it exists. 383 if key == "omit" { 384 if strings.Contains(value, "dev") { 385 nca.typeRestriction = prodOnly 386 } else { 387 nca.typeRestriction = all 388 } 389 } else if nca.typeRestriction == defaultRestriction { // Until npm 6, configurations in 'npm config ls' are sorted by priority in descending order, so typeRestriction should be set only if it was not set before 390 if key == "only" { 391 if strings.Contains(value, "prod") { 392 nca.typeRestriction = prodOnly 393 } else if strings.Contains(value, "dev") { 394 nca.typeRestriction = devOnly 395 } 396 } else if key == "production" && strings.Contains(value, "true") { 397 nca.typeRestriction = prodOnly 398 } 399 } 400 } 401 402 // Run npm list and parse the returned JSON. 403 // typeRestriction must be one of: 'dev' or 'prod'! 404 func (nca *NpmCommandArgs) prepareDependencies(typeRestriction string) error { 405 // Run npm list 406 // Although this command can get --development as a flag (according to npm docs), it's not working on npm 6. 407 // Although this command can get --only=development as a flag (according to npm docs), it's not working on npm 7. 408 data, errData, err := npm.RunList(strings.Join(append(nca.npmArgs, "--all", "--"+typeRestriction), " "), nca.executablePath) 409 if err != nil { 410 log.Warn("npm list command failed with error:", err.Error()) 411 } 412 if len(errData) > 0 { 413 log.Warn("Some errors occurred while collecting dependencies info:\n" + string(errData)) 414 } 415 416 // Parse the dependencies json object 417 return jsonparser.ObjectEach(data, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) (err error) { 418 if string(key) == "dependencies" { 419 err = nca.parseDependencies(value, typeRestriction, []string{nca.packageInfo.BuildInfoModuleId()}) 420 } 421 return err 422 }) 423 } 424 425 // Parses npm dependencies recursively and adds the collected dependencies to nca.dependencies 426 func (nca *NpmCommandArgs) parseDependencies(data []byte, scope string, pathToRoot []string) error { 427 return jsonparser.ObjectEach(data, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error { 428 depName := string(key) 429 ver, _, _, err := jsonparser.Get(data, depName, "version") 430 depVersion := string(ver) 431 depKey := depName + ":" + depVersion 432 if err != nil && err != jsonparser.KeyPathNotFoundError { 433 return errorutils.CheckError(err) 434 } else if err == jsonparser.KeyPathNotFoundError { 435 log.Debug(fmt.Sprintf("%s dependency will not be included in the build-info, because the 'npm ls' command did not return its version.\nThe reason why the version wasn't returned may be because the package is a 'peerdependency', which was not manually installed.\n'npm install' does not download 'peerdependencies' automatically. It is therefore okay to skip this dependency.", depName)) 436 } else { 437 nca.appendDependency(depKey, depName, depVersion, scope, pathToRoot) 438 } 439 transitive, _, _, err := jsonparser.Get(data, depName, "dependencies") 440 if err != nil && err.Error() != "Key path not found" { 441 return errorutils.CheckError(err) 442 } 443 if len(transitive) > 0 { 444 if err := nca.parseDependencies(transitive, scope, append([]string{depKey}, pathToRoot...)); err != nil { 445 return err 446 } 447 } 448 return nil 449 }) 450 } 451 452 func (nca *NpmCommandArgs) appendDependency(depKey, depName, depVersion, scope string, pathToRoot []string) { 453 if nca.dependencies[depKey] == nil { 454 nca.dependencies[depKey] = &dependency{name: depName, version: depVersion, scopes: []string{scope}} 455 } else if !scopeAlreadyExists(scope, nca.dependencies[depKey].scopes) { 456 nca.dependencies[depKey].scopes = append(nca.dependencies[depKey].scopes, scope) 457 } 458 nca.dependencies[depKey].pathToRoot = append(nca.dependencies[depKey].pathToRoot, pathToRoot) 459 } 460 461 // Creates a function that fetches dependency data. 462 // If a dependency was included in the previous build, take the checksums information from it. 463 // Otherwise, fetch the checksum from Artifactory. 464 // Can be applied from a producer-consumer mechanism. 465 func (nca *NpmCommandArgs) createGetDependencyInfoFunc(servicesManager artifactory.ArtifactoryServicesManager, 466 previousBuildDependencies map[string]*buildinfo.Dependency) getDependencyInfoFunc { 467 return func(dependencyIndex string) parallel.TaskFunc { 468 return func(threadId int) error { 469 name := nca.dependencies[dependencyIndex].name 470 ver := nca.dependencies[dependencyIndex].version 471 472 // Get dependency info. 473 checksum, fileType, err := commandUtils.GetDependencyInfo(name, ver, previousBuildDependencies, servicesManager, threadId) 474 if err != nil || checksum.IsEmpty() { 475 return err 476 } 477 478 // Update dependency. 479 nca.dependencies[dependencyIndex].fileType = fileType 480 nca.dependencies[dependencyIndex].checksum = checksum 481 return nil 482 } 483 } 484 } 485 486 // Transforms the list of dependencies to buildinfo.Dependencies list and creates a list of dependencies that are missing in Artifactory. 487 func (nca *NpmCommandArgs) transformDependencies() (dependencies []buildinfo.Dependency, missingDependencies []buildinfo.Dependency) { 488 for _, dependency := range nca.dependencies { 489 biDependency := buildinfo.Dependency{Id: dependency.name + ":" + dependency.version, Type: dependency.fileType, 490 Scopes: dependency.scopes, Checksum: dependency.checksum, RequestedBy: dependency.pathToRoot} 491 if !dependency.checksum.IsEmpty() { 492 dependencies = append(dependencies, 493 biDependency) 494 } else { 495 missingDependencies = append(missingDependencies, biDependency) 496 } 497 } 498 return 499 } 500 501 func (nca *NpmCommandArgs) restoreNpmrcAndError(err error) error { 502 if restoreErr := nca.restoreNpmrcFunc(); restoreErr != nil { 503 return errorutils.CheckError(errors.New(fmt.Sprintf("Two errors occurred:\n %s\n %s", restoreErr.Error(), err.Error()))) 504 } 505 return err 506 } 507 508 func (nca *NpmCommandArgs) setArtifactoryAuth() error { 509 authArtDetails, err := nca.serverDetails.CreateArtAuthConfig() 510 if err != nil { 511 return err 512 } 513 if authArtDetails.GetSshAuthHeaders() != nil { 514 return errorutils.CheckError(errors.New("SSH authentication is not supported in this command")) 515 } 516 nca.authArtDetails = authArtDetails 517 return nil 518 } 519 520 func removeNpmrcIfExists(workingDirectory string) error { 521 if _, err := os.Stat(filepath.Join(workingDirectory, npmrcFileName)); err != nil { 522 if os.IsNotExist(err) { // The file dose not exist, nothing to do. 523 return nil 524 } 525 return errorutils.CheckError(err) 526 } 527 528 log.Debug("Removing Existing .npmrc file") 529 return errorutils.CheckError(os.Remove(filepath.Join(workingDirectory, npmrcFileName))) 530 } 531 532 func (nca *NpmCommandArgs) setNpmExecutable() error { 533 npmExecPath, err := exec.LookPath("npm") 534 if err != nil { 535 return errorutils.CheckError(err) 536 } 537 538 if npmExecPath == "" { 539 return errorutils.CheckError(errors.New("could not find 'npm' executable")) 540 } 541 nca.executablePath = npmExecPath 542 log.Debug("Found npm executable at:", nca.executablePath) 543 return nil 544 } 545 546 func scopeAlreadyExists(scope string, existingScopes []string) bool { 547 for _, existingScope := range existingScopes { 548 if existingScope == scope { 549 return true 550 } 551 } 552 return false 553 } 554 555 // To avoid writing configurations that are used by us 556 func isValidKey(key string) bool { 557 return !strings.HasPrefix(key, "//") && 558 !strings.HasPrefix(key, ";") && // Comments 559 !strings.HasPrefix(key, "@") && // Scoped configurations 560 key != "registry" && 561 key != "metrics-registry" && 562 key != "json" // Handled separately because 'npm c ls' should run with json=false 563 } 564 565 func filterFlags(splitArgs []string) []string { 566 var filteredArgs []string 567 for _, arg := range splitArgs { 568 if !strings.HasPrefix(arg, "-") { 569 filteredArgs = append(filteredArgs, arg) 570 } 571 } 572 return filteredArgs 573 } 574 575 type getDependencyInfoFunc func(string) parallel.TaskFunc 576 577 type dependency struct { 578 name string 579 version string 580 scopes []string 581 fileType string 582 checksum buildinfo.Checksum 583 pathToRoot [][]string 584 }