github.com/rstandt/terraform@v0.12.32-0.20230710220336-b1063613405c/internal/initwd/module_install.go (about) 1 package initwd 2 3 import ( 4 "fmt" 5 "log" 6 "os" 7 "path/filepath" 8 "strings" 9 10 version "github.com/hashicorp/go-version" 11 "github.com/hashicorp/terraform-config-inspect/tfconfig" 12 "github.com/hashicorp/terraform/addrs" 13 "github.com/hashicorp/terraform/internal/earlyconfig" 14 "github.com/hashicorp/terraform/internal/modsdir" 15 "github.com/hashicorp/terraform/registry" 16 "github.com/hashicorp/terraform/registry/regsrc" 17 "github.com/hashicorp/terraform/registry/response" 18 "github.com/hashicorp/terraform/tfdiags" 19 ) 20 21 type ModuleInstaller struct { 22 modsDir string 23 reg *registry.Client 24 25 // The keys in moduleVersions are resolved and trimmed registry source 26 // addresses and the values are the registry response. 27 moduleVersions map[string]*response.ModuleVersions 28 29 // The keys in moduleVersionsUrl are the moduleVersion struct below and 30 // addresses and the values are the download URLs. 31 moduleVersionsUrl map[moduleVersion]string 32 } 33 34 type moduleVersion struct { 35 module string 36 version string 37 } 38 39 func NewModuleInstaller(modsDir string, reg *registry.Client) *ModuleInstaller { 40 return &ModuleInstaller{ 41 modsDir: modsDir, 42 reg: reg, 43 moduleVersions: make(map[string]*response.ModuleVersions), 44 moduleVersionsUrl: make(map[moduleVersion]string), 45 } 46 } 47 48 // InstallModules analyses the root module in the given directory and installs 49 // all of its direct and transitive dependencies into the given modules 50 // directory, which must already exist. 51 // 52 // Since InstallModules makes possibly-time-consuming calls to remote services, 53 // a hook interface is supported to allow the caller to be notified when 54 // each module is installed and, for remote modules, when downloading begins. 55 // LoadConfig guarantees that two hook calls will not happen concurrently but 56 // it does not guarantee any particular ordering of hook calls. This mechanism 57 // is for UI feedback only and does not give the caller any control over the 58 // process. 59 // 60 // If modules are already installed in the target directory, they will be 61 // skipped unless their source address or version have changed or unless 62 // the upgrade flag is set. 63 // 64 // InstallModules never deletes any directory, except in the case where it 65 // needs to replace a directory that is already present with a newly-extracted 66 // package. 67 // 68 // If the returned diagnostics contains errors then the module installation 69 // may have wholly or partially completed. Modules must be loaded in order 70 // to find their dependencies, so this function does many of the same checks 71 // as LoadConfig as a side-effect. 72 // 73 // If successful (the returned diagnostics contains no errors) then the 74 // first return value is the early configuration tree that was constructed by 75 // the installation process. 76 func (i *ModuleInstaller) InstallModules(rootDir string, upgrade bool, hooks ModuleInstallHooks) (*earlyconfig.Config, tfdiags.Diagnostics) { 77 log.Printf("[TRACE] ModuleInstaller: installing child modules for %s into %s", rootDir, i.modsDir) 78 79 rootMod, diags := earlyconfig.LoadModule(rootDir) 80 if rootMod == nil { 81 return nil, diags 82 } 83 84 manifest, err := modsdir.ReadManifestSnapshotForDir(i.modsDir) 85 if err != nil { 86 diags = diags.Append(tfdiags.Sourceless( 87 tfdiags.Error, 88 "Failed to read modules manifest file", 89 fmt.Sprintf("Error reading manifest for %s: %s.", i.modsDir, err), 90 )) 91 return nil, diags 92 } 93 94 getter := reusingGetter{} 95 cfg, instDiags := i.installDescendentModules(rootMod, rootDir, manifest, upgrade, hooks, getter) 96 diags = append(diags, instDiags...) 97 98 return cfg, diags 99 } 100 101 func (i *ModuleInstaller) installDescendentModules(rootMod *tfconfig.Module, rootDir string, manifest modsdir.Manifest, upgrade bool, hooks ModuleInstallHooks, getter reusingGetter) (*earlyconfig.Config, tfdiags.Diagnostics) { 102 var diags tfdiags.Diagnostics 103 104 if hooks == nil { 105 // Use our no-op implementation as a placeholder 106 hooks = ModuleInstallHooksImpl{} 107 } 108 109 // Create a manifest record for the root module. This will be used if 110 // there are any relative-pathed modules in the root. 111 manifest[""] = modsdir.Record{ 112 Key: "", 113 Dir: rootDir, 114 } 115 116 cfg, cDiags := earlyconfig.BuildConfig(rootMod, earlyconfig.ModuleWalkerFunc( 117 func(req *earlyconfig.ModuleRequest) (*tfconfig.Module, *version.Version, tfdiags.Diagnostics) { 118 119 key := manifest.ModuleKey(req.Path) 120 instPath := i.packageInstallPath(req.Path) 121 122 log.Printf("[DEBUG] Module installer: begin %s", key) 123 124 // First we'll check if we need to upgrade/replace an existing 125 // installed module, and delete it out of the way if so. 126 replace := upgrade 127 if !replace { 128 record, recorded := manifest[key] 129 switch { 130 case !recorded: 131 log.Printf("[TRACE] ModuleInstaller: %s is not yet installed", key) 132 replace = true 133 case record.SourceAddr != req.SourceAddr: 134 log.Printf("[TRACE] ModuleInstaller: %s source address has changed from %q to %q", key, record.SourceAddr, req.SourceAddr) 135 replace = true 136 case record.Version != nil && !req.VersionConstraints.Check(record.Version): 137 log.Printf("[TRACE] ModuleInstaller: %s version %s no longer compatible with constraints %s", key, record.Version, req.VersionConstraints) 138 replace = true 139 } 140 } 141 142 // If we _are_ planning to replace this module, then we'll remove 143 // it now so our installation code below won't conflict with any 144 // existing remnants. 145 if replace { 146 if _, recorded := manifest[key]; recorded { 147 log.Printf("[TRACE] ModuleInstaller: discarding previous record of %s prior to reinstall", key) 148 } 149 delete(manifest, key) 150 // Deleting a module invalidates all of its descendent modules too. 151 keyPrefix := key + "." 152 for subKey := range manifest { 153 if strings.HasPrefix(subKey, keyPrefix) { 154 if _, recorded := manifest[subKey]; recorded { 155 log.Printf("[TRACE] ModuleInstaller: also discarding downstream %s", subKey) 156 } 157 delete(manifest, subKey) 158 } 159 } 160 } 161 162 record, recorded := manifest[key] 163 if !recorded { 164 // Clean up any stale cache directory that might be present. 165 // If this is a local (relative) source then the dir will 166 // not exist, but we'll ignore that. 167 log.Printf("[TRACE] ModuleInstaller: cleaning directory %s prior to install of %s", instPath, key) 168 err := os.RemoveAll(instPath) 169 if err != nil && !os.IsNotExist(err) { 170 log.Printf("[TRACE] ModuleInstaller: failed to remove %s: %s", key, err) 171 diags = diags.Append(tfdiags.Sourceless( 172 tfdiags.Error, 173 "Failed to remove local module cache", 174 fmt.Sprintf( 175 "Terraform tried to remove %s in order to reinstall this module, but encountered an error: %s", 176 instPath, err, 177 ), 178 )) 179 return nil, nil, diags 180 } 181 } else { 182 // If this module is already recorded and its root directory 183 // exists then we will just load what's already there and 184 // keep our existing record. 185 info, err := os.Stat(record.Dir) 186 if err == nil && info.IsDir() { 187 mod, mDiags := earlyconfig.LoadModule(record.Dir) 188 diags = diags.Append(mDiags) 189 190 log.Printf("[TRACE] ModuleInstaller: Module installer: %s %s already installed in %s", key, record.Version, record.Dir) 191 return mod, record.Version, diags 192 } 193 } 194 195 // If we get down here then it's finally time to actually install 196 // the module. There are some variants to this process depending 197 // on what type of module source address we have. 198 switch { 199 200 case isLocalSourceAddr(req.SourceAddr): 201 log.Printf("[TRACE] ModuleInstaller: %s has local path %q", key, req.SourceAddr) 202 mod, mDiags := i.installLocalModule(req, key, manifest, hooks) 203 diags = append(diags, mDiags...) 204 return mod, nil, diags 205 206 case isRegistrySourceAddr(req.SourceAddr): 207 addr, err := regsrc.ParseModuleSource(req.SourceAddr) 208 if err != nil { 209 // Should never happen because isRegistrySourceAddr already validated 210 panic(err) 211 } 212 log.Printf("[TRACE] ModuleInstaller: %s is a registry module at %s", key, addr) 213 214 mod, v, mDiags := i.installRegistryModule(req, key, instPath, addr, manifest, hooks, getter) 215 diags = append(diags, mDiags...) 216 return mod, v, diags 217 218 default: 219 log.Printf("[TRACE] ModuleInstaller: %s address %q will be handled by go-getter", key, req.SourceAddr) 220 221 mod, mDiags := i.installGoGetterModule(req, key, instPath, manifest, hooks, getter) 222 diags = append(diags, mDiags...) 223 return mod, nil, diags 224 } 225 226 }, 227 )) 228 diags = append(diags, cDiags...) 229 230 err := manifest.WriteSnapshotToDir(i.modsDir) 231 if err != nil { 232 diags = diags.Append(tfdiags.Sourceless( 233 tfdiags.Error, 234 "Failed to update module manifest", 235 fmt.Sprintf("Unable to write the module manifest file: %s", err), 236 )) 237 } 238 239 return cfg, diags 240 } 241 242 func (i *ModuleInstaller) installLocalModule(req *earlyconfig.ModuleRequest, key string, manifest modsdir.Manifest, hooks ModuleInstallHooks) (*tfconfig.Module, tfdiags.Diagnostics) { 243 var diags tfdiags.Diagnostics 244 245 parentKey := manifest.ModuleKey(req.Parent.Path) 246 parentRecord, recorded := manifest[parentKey] 247 if !recorded { 248 // This is indicative of a bug rather than a user-actionable error 249 panic(fmt.Errorf("missing manifest record for parent module %s", parentKey)) 250 } 251 252 if len(req.VersionConstraints) != 0 { 253 diags = diags.Append(tfdiags.Sourceless( 254 tfdiags.Error, 255 "Invalid version constraint", 256 fmt.Sprintf("Cannot apply a version constraint to module %q (at %s:%d) because it has a relative local path.", req.Name, req.CallPos.Filename, req.CallPos.Line), 257 )) 258 } 259 260 // For local sources we don't actually need to modify the 261 // filesystem at all because the parent already wrote 262 // the files we need, and so we just load up what's already here. 263 newDir := filepath.Join(parentRecord.Dir, req.SourceAddr) 264 265 log.Printf("[TRACE] ModuleInstaller: %s uses directory from parent: %s", key, newDir) 266 // it is possible that the local directory is a symlink 267 newDir, err := filepath.EvalSymlinks(newDir) 268 if err != nil { 269 diags = diags.Append(tfdiags.Sourceless( 270 tfdiags.Error, 271 "Unreadable module directory", 272 fmt.Sprintf("Unable to evaluate directory symlink: %s", err.Error()), 273 )) 274 } 275 276 mod, mDiags := earlyconfig.LoadModule(newDir) 277 if mod == nil { 278 // nil indicates missing or unreadable directory, so we'll 279 // discard the returned diags and return a more specific 280 // error message here. 281 diags = diags.Append(tfdiags.Sourceless( 282 tfdiags.Error, 283 "Unreadable module directory", 284 fmt.Sprintf("The directory %s could not be read for module %q at %s:%d.", newDir, req.Name, req.CallPos.Filename, req.CallPos.Line), 285 )) 286 } else { 287 diags = diags.Append(mDiags) 288 } 289 290 // Note the local location in our manifest. 291 manifest[key] = modsdir.Record{ 292 Key: key, 293 Dir: newDir, 294 SourceAddr: req.SourceAddr, 295 } 296 log.Printf("[DEBUG] Module installer: %s installed at %s", key, newDir) 297 hooks.Install(key, nil, newDir) 298 299 return mod, diags 300 } 301 302 func (i *ModuleInstaller) installRegistryModule(req *earlyconfig.ModuleRequest, key string, instPath string, addr *regsrc.Module, manifest modsdir.Manifest, hooks ModuleInstallHooks, getter reusingGetter) (*tfconfig.Module, *version.Version, tfdiags.Diagnostics) { 303 var diags tfdiags.Diagnostics 304 305 hostname, err := addr.SvcHost() 306 if err != nil { 307 // If it looks like the user was trying to use punycode then we'll generate 308 // a specialized error for that case. We require the unicode form of 309 // hostname so that hostnames are always human-readable in configuration 310 // and punycode can't be used to hide a malicious module hostname. 311 if strings.HasPrefix(addr.RawHost.Raw, "xn--") { 312 diags = diags.Append(tfdiags.Sourceless( 313 tfdiags.Error, 314 "Invalid module registry hostname", 315 fmt.Sprintf("The hostname portion of the module %q source address (at %s:%d) is not an acceptable hostname. Internationalized domain names must be given in unicode form rather than ASCII (\"punycode\") form.", req.Name, req.CallPos.Filename, req.CallPos.Line), 316 )) 317 } else { 318 diags = diags.Append(tfdiags.Sourceless( 319 tfdiags.Error, 320 "Invalid module registry hostname", 321 fmt.Sprintf("The hostname portion of the module %q source address (at %s:%d) is not a valid hostname.", req.Name, req.CallPos.Filename, req.CallPos.Line), 322 )) 323 } 324 return nil, nil, diags 325 } 326 327 reg := i.reg 328 var resp *response.ModuleVersions 329 var exists bool 330 331 // check if we've already looked up this module from the registry 332 if resp, exists = i.moduleVersions[addr.String()]; exists { 333 log.Printf("[TRACE] %s using already found available versions of %s at %s", key, addr, hostname) 334 } else { 335 log.Printf("[DEBUG] %s listing available versions of %s at %s", key, addr, hostname) 336 resp, err = reg.ModuleVersions(addr) 337 if err != nil { 338 if registry.IsModuleNotFound(err) { 339 diags = diags.Append(tfdiags.Sourceless( 340 tfdiags.Error, 341 "Module not found", 342 fmt.Sprintf("Module %q (from %s:%d) cannot be found in the module registry at %s.", req.Name, req.CallPos.Filename, req.CallPos.Line, hostname), 343 )) 344 } else { 345 diags = diags.Append(tfdiags.Sourceless( 346 tfdiags.Error, 347 "Error accessing remote module registry", 348 fmt.Sprintf("Failed to retrieve available versions for module %q (%s:%d) from %s: %s.", req.Name, req.CallPos.Filename, req.CallPos.Line, hostname, err), 349 )) 350 } 351 return nil, nil, diags 352 } 353 i.moduleVersions[addr.String()] = resp 354 } 355 356 // The response might contain information about dependencies to allow us 357 // to potentially optimize future requests, but we don't currently do that 358 // and so for now we'll just take the first item which is guaranteed to 359 // be the address we requested. 360 if len(resp.Modules) < 1 { 361 // Should never happen, but since this is a remote service that may 362 // be implemented by third-parties we will handle it gracefully. 363 diags = diags.Append(tfdiags.Sourceless( 364 tfdiags.Error, 365 "Invalid response from remote module registry", 366 fmt.Sprintf("The registry at %s returned an invalid response when Terraform requested available versions for module %q (%s:%d).", hostname, req.Name, req.CallPos.Filename, req.CallPos.Line), 367 )) 368 return nil, nil, diags 369 } 370 371 modMeta := resp.Modules[0] 372 373 var latestMatch *version.Version 374 var latestVersion *version.Version 375 for _, mv := range modMeta.Versions { 376 v, err := version.NewVersion(mv.Version) 377 if err != nil { 378 // Should never happen if the registry server is compliant with 379 // the protocol, but we'll warn if not to assist someone who 380 // might be developing a module registry server. 381 diags = diags.Append(tfdiags.Sourceless( 382 tfdiags.Warning, 383 "Invalid response from remote module registry", 384 fmt.Sprintf("The registry at %s returned an invalid version string %q for module %q (%s:%d), which Terraform ignored.", hostname, mv.Version, req.Name, req.CallPos.Filename, req.CallPos.Line), 385 )) 386 continue 387 } 388 389 // If we've found a pre-release version then we'll ignore it unless 390 // it was exactly requested. 391 if v.Prerelease() != "" && req.VersionConstraints.String() != v.String() { 392 log.Printf("[TRACE] ModuleInstaller: %s ignoring %s because it is a pre-release and was not requested exactly", key, v) 393 continue 394 } 395 396 if latestVersion == nil || v.GreaterThan(latestVersion) { 397 latestVersion = v 398 } 399 400 if req.VersionConstraints.Check(v) { 401 if latestMatch == nil || v.GreaterThan(latestMatch) { 402 latestMatch = v 403 } 404 } 405 } 406 407 if latestVersion == nil { 408 diags = diags.Append(tfdiags.Sourceless( 409 tfdiags.Error, 410 "Module has no versions", 411 fmt.Sprintf("Module %q (%s:%d) has no versions available on %s.", addr, req.CallPos.Filename, req.CallPos.Line, hostname), 412 )) 413 return nil, nil, diags 414 } 415 416 if latestMatch == nil { 417 diags = diags.Append(tfdiags.Sourceless( 418 tfdiags.Error, 419 "Unresolvable module version constraint", 420 fmt.Sprintf("There is no available version of module %q (%s:%d) which matches the given version constraint. The newest available version is %s.", addr, req.CallPos.Filename, req.CallPos.Line, latestVersion), 421 )) 422 return nil, nil, diags 423 } 424 425 // Report up to the caller that we're about to start downloading. 426 packageAddr, _ := splitAddrSubdir(req.SourceAddr) 427 hooks.Download(key, packageAddr, latestMatch) 428 429 // If we manage to get down here then we've found a suitable version to 430 // install, so we need to ask the registry where we should download it from. 431 // The response to this is a go-getter-style address string. 432 433 // first check the cache for the download URL 434 moduleAddr := moduleVersion{module: addr.String(), version: latestMatch.String()} 435 if _, exists := i.moduleVersionsUrl[moduleAddr]; !exists { 436 url, err := reg.ModuleLocation(addr, latestMatch.String()) 437 if err != nil { 438 log.Printf("[ERROR] %s from %s %s: %s", key, addr, latestMatch, err) 439 diags = diags.Append(tfdiags.Sourceless( 440 tfdiags.Error, 441 "Error accessing remote module registry", 442 fmt.Sprintf("Failed to retrieve a download URL for %s %s from %s: %s", addr, latestMatch, hostname, err), 443 )) 444 return nil, nil, diags 445 } 446 i.moduleVersionsUrl[moduleVersion{module: addr.String(), version: latestMatch.String()}] = url 447 } 448 449 dlAddr := i.moduleVersionsUrl[moduleAddr] 450 451 log.Printf("[TRACE] ModuleInstaller: %s %s %s is available at %q", key, addr, latestMatch, dlAddr) 452 453 modDir, err := getter.getWithGoGetter(instPath, dlAddr) 454 if err != nil { 455 // Errors returned by go-getter have very inconsistent quality as 456 // end-user error messages, but for now we're accepting that because 457 // we have no way to recognize any specific errors to improve them 458 // and masking the error entirely would hide valuable diagnostic 459 // information from the user. 460 diags = diags.Append(tfdiags.Sourceless( 461 tfdiags.Error, 462 "Failed to download module", 463 fmt.Sprintf("Could not download module %q (%s:%d) source code from %q: %s.", req.Name, req.CallPos.Filename, req.CallPos.Line, dlAddr, err), 464 )) 465 return nil, nil, diags 466 } 467 468 log.Printf("[TRACE] ModuleInstaller: %s %q was downloaded to %s", key, dlAddr, modDir) 469 470 if addr.RawSubmodule != "" { 471 // Append the user's requested subdirectory to any subdirectory that 472 // was implied by any of the nested layers we expanded within go-getter. 473 modDir = filepath.Join(modDir, addr.RawSubmodule) 474 } 475 476 log.Printf("[TRACE] ModuleInstaller: %s should now be at %s", key, modDir) 477 478 // Finally we are ready to try actually loading the module. 479 mod, mDiags := earlyconfig.LoadModule(modDir) 480 if mod == nil { 481 // nil indicates missing or unreadable directory, so we'll 482 // discard the returned diags and return a more specific 483 // error message here. For registry modules this actually 484 // indicates a bug in the code above, since it's not the 485 // user's responsibility to create the directory in this case. 486 diags = diags.Append(tfdiags.Sourceless( 487 tfdiags.Error, 488 "Unreadable module directory", 489 fmt.Sprintf("The directory %s could not be read. This is a bug in Terraform and should be reported.", modDir), 490 )) 491 } else { 492 diags = append(diags, mDiags...) 493 } 494 495 // Note the local location in our manifest. 496 manifest[key] = modsdir.Record{ 497 Key: key, 498 Version: latestMatch, 499 Dir: modDir, 500 SourceAddr: req.SourceAddr, 501 } 502 log.Printf("[DEBUG] Module installer: %s installed at %s", key, modDir) 503 hooks.Install(key, latestMatch, modDir) 504 505 return mod, latestMatch, diags 506 } 507 508 func (i *ModuleInstaller) installGoGetterModule(req *earlyconfig.ModuleRequest, key string, instPath string, manifest modsdir.Manifest, hooks ModuleInstallHooks, getter reusingGetter) (*tfconfig.Module, tfdiags.Diagnostics) { 509 var diags tfdiags.Diagnostics 510 511 // Report up to the caller that we're about to start downloading. 512 packageAddr, _ := splitAddrSubdir(req.SourceAddr) 513 hooks.Download(key, packageAddr, nil) 514 515 if len(req.VersionConstraints) != 0 { 516 diags = diags.Append(tfdiags.Sourceless( 517 tfdiags.Error, 518 "Invalid version constraint", 519 fmt.Sprintf("Cannot apply a version constraint to module %q (at %s:%d) because it has a non Registry URL.", req.Name, req.CallPos.Filename, req.CallPos.Line), 520 )) 521 return nil, diags 522 } 523 524 modDir, err := getter.getWithGoGetter(instPath, req.SourceAddr) 525 if err != nil { 526 if _, ok := err.(*MaybeRelativePathErr); ok { 527 log.Printf( 528 "[TRACE] ModuleInstaller: %s looks like a local path but is missing ./ or ../", 529 req.SourceAddr, 530 ) 531 diags = diags.Append(tfdiags.Sourceless( 532 tfdiags.Error, 533 "Module not found", 534 fmt.Sprintf( 535 "The module address %q could not be resolved.\n\n"+ 536 "If you intended this as a path relative to the current "+ 537 "module, use \"./%s\" instead. The \"./\" prefix "+ 538 "indicates that the address is a relative filesystem path.", 539 req.SourceAddr, req.SourceAddr, 540 ), 541 )) 542 } else { 543 // Errors returned by go-getter have very inconsistent quality as 544 // end-user error messages, but for now we're accepting that because 545 // we have no way to recognize any specific errors to improve them 546 // and masking the error entirely would hide valuable diagnostic 547 // information from the user. 548 diags = diags.Append(tfdiags.Sourceless( 549 tfdiags.Error, 550 "Failed to download module", 551 fmt.Sprintf("Could not download module %q (%s:%d) source code from %q: %s", req.Name, req.CallPos.Filename, req.CallPos.Line, packageAddr, err), 552 )) 553 } 554 return nil, diags 555 556 } 557 558 log.Printf("[TRACE] ModuleInstaller: %s %q was downloaded to %s", key, req.SourceAddr, modDir) 559 560 mod, mDiags := earlyconfig.LoadModule(modDir) 561 if mod == nil { 562 // nil indicates missing or unreadable directory, so we'll 563 // discard the returned diags and return a more specific 564 // error message here. For go-getter modules this actually 565 // indicates a bug in the code above, since it's not the 566 // user's responsibility to create the directory in this case. 567 diags = diags.Append(tfdiags.Sourceless( 568 tfdiags.Error, 569 "Unreadable module directory", 570 fmt.Sprintf("The directory %s could not be read. This is a bug in Terraform and should be reported.", modDir), 571 )) 572 } else { 573 diags = append(diags, mDiags...) 574 } 575 576 // Note the local location in our manifest. 577 manifest[key] = modsdir.Record{ 578 Key: key, 579 Dir: modDir, 580 SourceAddr: req.SourceAddr, 581 } 582 log.Printf("[DEBUG] Module installer: %s installed at %s", key, modDir) 583 hooks.Install(key, nil, modDir) 584 585 return mod, diags 586 } 587 588 func (i *ModuleInstaller) packageInstallPath(modulePath addrs.Module) string { 589 return filepath.Join(i.modsDir, strings.Join(modulePath, ".")) 590 }