327 lines
9.2 KiB
Go
327 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"time"
|
|
|
|
"flag"
|
|
|
|
"io"
|
|
|
|
getter "github.com/hashicorp/go-getter"
|
|
"github.com/hashicorp/terraform/plugin"
|
|
discovery "github.com/hashicorp/terraform/plugin/discovery"
|
|
"github.com/mitchellh/cli"
|
|
)
|
|
|
|
type PackageCommand struct {
|
|
ui cli.Ui
|
|
}
|
|
|
|
// shameless stackoverflow copy + pasta https://stackoverflow.com/questions/21060945/simple-way-to-copy-a-file-in-golang
|
|
func CopyFile(src, dst string) (err error) {
|
|
sfi, err := os.Stat(src)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if !sfi.Mode().IsRegular() {
|
|
// cannot copy non-regular files (e.g., directories,
|
|
// symlinks, devices, etc.)
|
|
return fmt.Errorf("CopyFile: non-regular source file %s (%q)", sfi.Name(), sfi.Mode().String())
|
|
}
|
|
dfi, err := os.Stat(dst)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return
|
|
}
|
|
} else {
|
|
if !(dfi.Mode().IsRegular()) {
|
|
return fmt.Errorf("CopyFile: non-regular destination file %s (%q)", dfi.Name(), dfi.Mode().String())
|
|
}
|
|
if os.SameFile(sfi, dfi) {
|
|
return
|
|
}
|
|
}
|
|
if err = os.Link(src, dst); err == nil {
|
|
return
|
|
}
|
|
err = copyFileContents(src, dst)
|
|
os.Chmod(dst, sfi.Mode())
|
|
return
|
|
}
|
|
|
|
// see above
|
|
func copyFileContents(src, dst string) (err error) {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer in.Close()
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer func() {
|
|
cerr := out.Close()
|
|
if err == nil {
|
|
err = cerr
|
|
}
|
|
}()
|
|
if _, err = io.Copy(out, in); err != nil {
|
|
return
|
|
}
|
|
err = out.Sync()
|
|
return
|
|
}
|
|
|
|
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
|
|
pluginDir := "./plugins"
|
|
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
|
|
}
|
|
|
|
if discovery.ConstraintStr("< 0.10.0-beta1").MustParse().Allows(config.Terraform.Version.MustParse()) {
|
|
c.ui.Error("Bundles can be created only for Terraform 0.10 or newer")
|
|
return 1
|
|
}
|
|
|
|
workDir, err := ioutil.TempDir("", "terraform-bundle")
|
|
if err != nil {
|
|
c.ui.Error(fmt.Sprintf("Could not create temporary dir: %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))
|
|
}
|
|
|
|
c.ui.Info(fmt.Sprintf("Fetching 3rd party plugins in directory: %s", pluginDir))
|
|
dirs := []string{pluginDir} //FindPlugins requires an array
|
|
localPlugins := discovery.FindPlugins("provider", dirs)
|
|
for k, _ := range localPlugins {
|
|
c.ui.Info(fmt.Sprintf("plugin: %s (%s)", k.Name, k.Version))
|
|
}
|
|
installer := &discovery.ProviderInstaller{
|
|
Dir: workDir,
|
|
|
|
// FIXME: This is incorrect because it uses the protocol version of
|
|
// this tool, rather than of the Terraform binary we just downloaded.
|
|
// But we can't get this information from a Terraform binary, so
|
|
// we'll just ignore this for now as we only have one protocol version
|
|
// in play anyway. If a new protocol version shows up later we will
|
|
// probably deal with this by just matching version ranges and
|
|
// hard-coding the knowledge of which Terraform version uses which
|
|
// protocol version.
|
|
PluginProtocolVersion: plugin.Handshake.ProtocolVersion,
|
|
|
|
OS: osName,
|
|
Arch: archName,
|
|
Ui: c.ui,
|
|
}
|
|
|
|
for name, constraintStrs := range config.Providers {
|
|
for _, constraintStr := range constraintStrs {
|
|
c.ui.Output(fmt.Sprintf("- Resolving %q provider (%s)...",
|
|
name, constraintStr))
|
|
foundPlugins := discovery.PluginMetaSet{}
|
|
constraint := constraintStr.MustParse()
|
|
for plugin, _ := range localPlugins {
|
|
if plugin.Name == name && constraint.Allows(plugin.Version.MustParse()) {
|
|
foundPlugins.Add(plugin)
|
|
}
|
|
}
|
|
|
|
if len(foundPlugins) > 0 {
|
|
plugin := foundPlugins.Newest()
|
|
CopyFile(plugin.Path, workDir+"/terraform-provider-"+plugin.Name+"_v"+plugin.Version.MustParse().String()) //put into temp dir
|
|
} else { //attempt to get from the public registry if not found locally
|
|
c.ui.Output(fmt.Sprintf("- Checking for provider plugin on %s...",
|
|
discovery.GetReleaseHost()))
|
|
_, err := installer.Get(name, constraint)
|
|
if err != nil {
|
|
c.ui.Error(fmt.Sprintf("- Failed to resolve %s provider %s: %s", name, constraint, err))
|
|
return 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
files, err := ioutil.ReadDir(workDir)
|
|
if err != nil {
|
|
c.ui.Error(fmt.Sprintf("Failed to read work directory %s: %s", workDir, err))
|
|
return 1
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}()
|
|
|
|
for _, file := range files {
|
|
if file.IsDir() {
|
|
// should never happen unless something tampers with our tmpdir
|
|
continue
|
|
}
|
|
|
|
fn := filepath.Join(workDir, file.Name())
|
|
r, err := os.Open(fn)
|
|
if err != nil {
|
|
c.ui.Error(fmt.Sprintf("Failed to open %s: %s", fn, err))
|
|
return 1
|
|
}
|
|
hdr, err := zip.FileInfoHeader(file)
|
|
if err != nil {
|
|
c.ui.Error(fmt.Sprintf("Failed to add zip entry for %s: %s", fn, err))
|
|
return 1
|
|
}
|
|
w, err := outZ.CreateHeader(hdr)
|
|
if err != nil {
|
|
c.ui.Error(fmt.Sprintf("Failed to add zip entry for %s: %s", fn, err))
|
|
return 1
|
|
}
|
|
_, err = io.Copy(w, r)
|
|
if err != nil {
|
|
c.ui.Error(fmt.Sprintf("Failed to write %s to bundle: %s", fn, err))
|
|
return 1
|
|
}
|
|
}
|
|
|
|
c.ui.Info("All done!")
|
|
|
|
return 0
|
|
}
|
|
|
|
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",
|
|
discovery.GetReleaseHost(), 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.10.0"
|
|
}
|
|
|
|
# Define which provider plugins are to be included
|
|
providers {
|
|
# Include the newest "aws" provider version in the 1.0 series.
|
|
aws = ["~> 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 = ["~> 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-*-v*, and must be built with the operating
|
|
#system and architecture that terraform enterprise is running, e.g. linux and amd64
|
|
customplugin = ["0.1"]
|
|
}
|
|
|
|
`
|
|
}
|