opentofu/tools/terraform-bundle/package.go
Martin Atkins b3f5c7f1e6 command/init: Read, respect, and update provider dependency locks
This changes the approach used by the provider installer to remember
between runs which selections it has previously made, using the lock file
format implemented in internal/depsfile.

This means that version constraints in the configuration are considered
only for providers we've not seen before or when -upgrade mode is active.
2020-10-09 09:26:23 -07:00

424 lines
13 KiB
Go

package main
import (
"archive/zip"
"context"
"flag"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"strings"
"time"
getter "github.com/hashicorp/go-getter"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/httpclient"
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/getproviders"
"github.com/hashicorp/terraform/internal/providercache"
discovery "github.com/hashicorp/terraform/plugin/discovery"
"github.com/hashicorp/terraform/tfdiags"
"github.com/hashicorp/terraform/version"
"github.com/mitchellh/cli"
)
var releaseHost = "https://releases.hashicorp.com"
var pluginDir = ".plugins"
type PackageCommand struct {
ui cli.Ui
}
func (c *PackageCommand) Run(args []string) int {
flags := flag.NewFlagSet("package", flag.ExitOnError)
osPtr := flags.String("os", "", "Target operating system")
archPtr := flags.String("arch", "", "Target CPU architecture")
pluginDirPtr := flags.String("plugin-dir", "", "Path to custom plugins directory")
err := flags.Parse(args)
if err != nil {
c.ui.Error(err.Error())
return 1
}
osName := runtime.GOOS
archName := runtime.GOARCH
if *osPtr != "" {
osName = *osPtr
}
if *archPtr != "" {
archName = *archPtr
}
if *pluginDirPtr != "" {
pluginDir = *pluginDirPtr
}
if flags.NArg() != 1 {
c.ui.Error("Configuration filename is required")
return 1
}
configFn := flags.Arg(0)
config, err := LoadConfigFile(configFn)
if err != nil {
c.ui.Error(fmt.Sprintf("Failed to read config: %s", err))
return 1
}
tmpDir, err := ioutil.TempDir("", "terraform-bundle")
if err != nil {
c.ui.Error(fmt.Sprintf("Could not create temporary dir: %s", err))
return 1
}
// symlinked tmp directories can cause odd behaviors.
workDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
c.ui.Error(fmt.Sprintf("Error evaulating symlinks: %s", err))
return 1
}
defer os.RemoveAll(workDir)
c.ui.Info(fmt.Sprintf("Fetching Terraform %s core package...", config.Terraform.Version))
coreZipURL := c.coreURL(config.Terraform.Version, osName, archName)
err = getter.Get(workDir, coreZipURL)
if err != nil {
c.ui.Error(fmt.Sprintf("Failed to fetch core package from %s: %s", coreZipURL, err))
return 1
}
// get the list of required providers from the config
reqs := make(map[addrs.Provider][]string)
for name, provider := range config.Providers {
var fqn addrs.Provider
var diags tfdiags.Diagnostics
if provider.Source != "" {
fqn, diags = addrs.ParseProviderSourceString(provider.Source)
if diags.HasErrors() {
c.ui.Error(fmt.Sprintf("Invalid provider source string: %s", provider.Source))
return 1
}
} else {
fqn = addrs.NewDefaultProvider(name)
}
reqs[fqn] = provider.Versions
}
// set up the provider installer
platform := getproviders.Platform{
OS: osName,
Arch: archName,
}
installdir := providercache.NewDirWithPlatform(filepath.Join(workDir, "plugins"), platform)
services := disco.New()
services.SetUserAgent(httpclient.TerraformUserAgent(version.String()))
var sources []getproviders.MultiSourceSelector
// Find any local providers first so we can exclude these from the registry
// install. We'll just silently ignore any errors and assume it would fail
// real installation later too.
foundLocally := map[addrs.Provider]struct{}{}
if absPluginDir, err := filepath.Abs(pluginDir); err == nil {
c.ui.Info(fmt.Sprintf("Local plugin directory %q found; scanning for provider binaries.", pluginDir))
if _, err := os.Stat(absPluginDir); err == nil {
localSource := getproviders.NewFilesystemMirrorSource(absPluginDir)
if available, err := localSource.AllAvailablePackages(); err == nil {
for found := range available {
c.ui.Info(fmt.Sprintf("Found provider %q in %q. p", found.String(), pluginDir))
foundLocally[found] = struct{}{}
}
}
sources = append(sources, getproviders.MultiSourceSelector{
Source: localSource,
})
if len(foundLocally) == 0 {
c.ui.Info(fmt.Sprintf("No local providers found in %q.", pluginDir))
}
} else {
c.ui.Info(fmt.Sprintf("No %q directory found, skipping local provider discovery.", pluginDir))
}
}
// Anything we found in local directories above is excluded from being
// looked up via the registry source we're about to construct.
var directExcluded getproviders.MultiSourceMatchingPatterns
for addr := range foundLocally {
directExcluded = append(directExcluded, addr)
}
// Add the registry source, minus any providers found in the local pluginDir.
sources = append(sources, getproviders.MultiSourceSelector{
Source: getproviders.NewMemoizeSource(getproviders.NewRegistrySource(services)),
Exclude: directExcluded,
})
installer := providercache.NewInstaller(installdir, getproviders.MultiSource(sources))
err = c.ensureProviderVersions(installer, reqs)
if err != nil {
c.ui.Error(err.Error())
return 1
}
// remove the selections.json file created by the provider installer
os.Remove(filepath.Join(workDir, "plugins", "selections.json"))
// If we get this far then our workDir now contains the union of the
// contents of all the zip files we downloaded above. We can now create
// our output file.
outFn := c.bundleFilename(config.Terraform.Version, time.Now(), osName, archName)
c.ui.Info(fmt.Sprintf("Creating %s ...", outFn))
outF, err := os.OpenFile(outFn, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
c.ui.Error(fmt.Sprintf("Failed to create %s: %s", outFn, err))
return 1
}
outZ := zip.NewWriter(outF)
defer func() {
err := outZ.Close()
if err != nil {
c.ui.Error(fmt.Sprintf("Failed to close %s: %s", outFn, err))
os.Exit(1)
}
err = outF.Close()
if err != nil {
c.ui.Error(fmt.Sprintf("Failed to close %s: %s", outFn, err))
os.Exit(1)
}
}()
// recursively walk the workDir to get a list of all binary filepaths
err = filepath.Walk(workDir,
func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// maybe symlinks
linkPath, err := filepath.EvalSymlinks(path)
if err != nil {
return err
}
linkInfo, err := os.Stat(linkPath)
if err != nil {
return err
}
if linkInfo.IsDir() {
// The only time we should encounter a symlink directory is when we
// have a locally-installed provider, so we will grab the provider
// binary from that file.
files, err := ioutil.ReadDir(linkPath)
if err != nil {
return err
}
for _, file := range files {
if strings.Contains(file.Name(), "terraform-provider") {
relPath, _ := filepath.Rel(workDir, path)
return addZipFile(
filepath.Join(linkPath, file.Name()), // the link to this provider binary
filepath.Join(relPath, file.Name()), // the expected directory for the binary
file, outZ,
)
}
}
// This shouldn't happen - we should always find a provider
// binary and exit the loop - but on the chance it does not,
// just continue.
return nil
}
// provider plugins need to be created in the same relative directory structure
absPath, err := filepath.Abs(linkPath)
if err != nil {
return err
}
relPath, err := filepath.Rel(workDir, absPath)
if err != nil {
return err
}
return addZipFile(path, relPath, info, outZ)
})
if err != nil {
c.ui.Error(err.Error())
return 1
}
c.ui.Info("All done!")
return 0
}
// addZipFile is a helper function intneded to simplify customizing the file
// path when adding a file to the zip archive. The relPath is specified for
// provider binaries, which need to be zipped into the full directory hierarchy.
func addZipFile(fn, relPath string, info os.FileInfo, outZ *zip.Writer) error {
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return fmt.Errorf("Failed to add zip entry for %s: %s", fn, err)
}
hdr.Method = zip.Deflate // be sure to compress files
hdr.Name = relPath // we need the full, relative path to the provider binary
w, err := outZ.CreateHeader(hdr)
if err != nil {
return fmt.Errorf("Failed to add zip entry for %s: %s", fn, err)
}
r, err := os.Open(fn)
if err != nil {
return fmt.Errorf("Failed to open %s: %s", fn, err)
}
_, err = io.Copy(w, r)
if err != nil {
return fmt.Errorf("Failed to write %s to bundle: %s", fn, err)
}
return nil
}
func (c *PackageCommand) bundleFilename(version discovery.VersionStr, time time.Time, osName, archName string) string {
time = time.UTC()
return fmt.Sprintf(
"terraform_%s-bundle%04d%02d%02d%02d_%s_%s.zip",
version,
time.Year(), time.Month(), time.Day(), time.Hour(),
osName, archName,
)
}
func (c *PackageCommand) coreURL(version discovery.VersionStr, osName, archName string) string {
return fmt.Sprintf(
"%s/terraform/%s/terraform_%s_%s_%s.zip",
releaseHost, version, version, osName, archName,
)
}
func (c *PackageCommand) Synopsis() string {
return "Produces a bundle archive"
}
func (c *PackageCommand) Help() string {
return `Usage: terraform-bundle package [options] <config-file>
Uses the given bundle configuration file to produce a zip file in the
current working directory containing a Terraform binary along with zero or
more provider plugin binaries.
Options:
-os=name Target operating system the archive will be built for. Defaults
to that of the system where the command is being run.
-arch=name Target CPU architecture the archive will be built for. Defaults
to that of the system where the command is being run.
-plugin-dir=path The path to the custom plugins directory. Defaults to "./plugins".
The resulting zip file can be used to more easily install Terraform and
a fixed set of providers together on a server, so that Terraform's provider
auto-installation mechanism can be avoided.
To build an archive for Terraform Enterprise, use:
-os=linux -arch=amd64
Note that the given configuration file is a format specific to this command,
not a normal Terraform configuration file. The file format looks like this:
terraform {
# Version of Terraform to include in the bundle. An exact version number
# is required.
version = "0.13.0"
}
# Define which provider plugins are to be included
providers {
# Include the newest "aws" provider version in the 1.0 series.
aws = {
versions = ["~> 1.0"]
}
# Include both the newest 1.0 and 2.0 versions of the "google" provider.
# Each item in these lists allows a distinct version to be added. If the
# two expressions match different versions then _both_ are included in
# the bundle archive.
google = {
versions = ["~> 1.0", "~> 2.0"]
}
# Include a custom plugin to the bundle. Will search for the plugin in the
# plugins directory, and package it with the bundle archive. Plugin must
# have a name of the form: terraform-provider-*, and must be built with
# the operating system and architecture that terraform enterprise is running,
# e.g. linux and amd64.
# See the README for more information on the source attribute and plugin
# directory layout.
customplugin = {
versions = ["0.1"]
source = "example.com/myorg/customplugin"
}
}
`
}
// ensureProviderVersions is a wrapper around
// providercache.EnsureProviderVersions which allows installing multiple
// versions of a given provider.
func (c *PackageCommand) ensureProviderVersions(installer *providercache.Installer, reqs map[addrs.Provider][]string) error {
mode := providercache.InstallNewProvidersOnly
evts := &providercache.InstallerEvents{
ProviderAlreadyInstalled: func(provider addrs.Provider, selectedVersion getproviders.Version) {
c.ui.Info(fmt.Sprintf("- Using previously-installed %s v%s", provider.ForDisplay(), selectedVersion))
},
QueryPackagesBegin: func(provider addrs.Provider, versionConstraints getproviders.VersionConstraints, locked bool) {
if len(versionConstraints) > 0 {
c.ui.Info(fmt.Sprintf("- Finding %s versions matching %q...", provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)))
} else {
c.ui.Info(fmt.Sprintf("- Finding latest version of %s...", provider.ForDisplay()))
}
},
FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) {
c.ui.Info(fmt.Sprintf("- Installing %s v%s...", provider.ForDisplay(), version))
},
QueryPackagesFailure: func(provider addrs.Provider, err error) {
c.ui.Error(fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s.", provider.ForDisplay(), err))
},
FetchPackageFailure: func(provider addrs.Provider, version getproviders.Version, err error) {
c.ui.Error(fmt.Sprintf("Error while installing %s v%s: %s.", provider.ForDisplay(), version, err))
},
}
ctx := evts.OnContext(context.TODO())
for provider, versions := range reqs {
for _, constraint := range versions {
req := make(getproviders.Requirements, 1)
cstr, err := getproviders.ParseVersionConstraints(constraint)
if err != nil {
return err
}
req[provider] = cstr
// We always start with no locks here, because we want to take
// the newest version matching the given version constraint, and
// never consider anything that might've been selected before.
locks := depsfile.NewLocks()
_, err = installer.EnsureProviderVersions(ctx, locks, req, mode)
if err != nil {
return err
}
}
}
return nil
}