github.com/mponton/terratest@v0.44.0/modules/packer/packer.go (about) 1 // Package packer allows to interact with Packer. 2 package packer 3 4 import ( 5 "errors" 6 "fmt" 7 "io/ioutil" 8 "os" 9 "path/filepath" 10 "regexp" 11 "sync" 12 "time" 13 14 "github.com/hashicorp/go-multierror" 15 "github.com/mponton/terratest/modules/retry" 16 "github.com/stretchr/testify/require" 17 18 "github.com/hashicorp/go-version" 19 "github.com/mponton/terratest/modules/logger" 20 "github.com/mponton/terratest/modules/shell" 21 "github.com/mponton/terratest/modules/testing" 22 ) 23 24 // Options are the options for Packer. 25 type Options struct { 26 Template string // The path to the Packer template 27 Vars map[string]string // The custom vars to pass when running the build command 28 VarFiles []string // Var file paths to pass Packer using -var-file option 29 Only string // If specified, only run the build of this name 30 Except string // Runs the build excluding the specified builds and post-processors 31 Env map[string]string // Custom environment variables to set when running Packer 32 RetryableErrors map[string]string // If packer build fails with one of these (transient) errors, retry. The keys are a regexp to match against the error and the message is what to display to a user if that error is matched. 33 MaxRetries int // Maximum number of times to retry errors matching RetryableErrors 34 TimeBetweenRetries time.Duration // The amount of time to wait between retries 35 WorkingDir string // The directory to run packer in 36 Logger *logger.Logger // If set, use a non-default logger 37 DisableTemporaryPluginPath bool // If set, do not use a temporary directory for Packer plugins. 38 } 39 40 // BuildArtifacts can take a map of identifierName <-> Options and then parallelize 41 // the packer builds. Once all the packer builds have completed a map of identifierName <-> generated identifier 42 // is returned. The identifierName can be anything you want, it is only used so that you can 43 // know which generated artifact is which. 44 func BuildArtifacts(t testing.TestingT, artifactNameToOptions map[string]*Options) map[string]string { 45 result, err := BuildArtifactsE(t, artifactNameToOptions) 46 47 if err != nil { 48 t.Fatalf("Error building artifacts: %s", err.Error()) 49 } 50 51 return result 52 } 53 54 // BuildArtifactsE can take a map of identifierName <-> Options and then parallelize 55 // the packer builds. Once all the packer builds have completed a map of identifierName <-> generated identifier 56 // is returned. If any artifact fails to build, the errors are accumulated and returned 57 // as a MultiError. The identifierName can be anything you want, it is only used so that you can 58 // know which generated artifact is which. 59 func BuildArtifactsE(t testing.TestingT, artifactNameToOptions map[string]*Options) (map[string]string, error) { 60 var waitForArtifacts sync.WaitGroup 61 waitForArtifacts.Add(len(artifactNameToOptions)) 62 63 var artifactNameToArtifactId = map[string]string{} 64 var errorsOccurred = new(multierror.Error) 65 66 for artifactName, curOptions := range artifactNameToOptions { 67 // The following is necessary to make sure artifactName and curOptions don't 68 // get updated due to concurrency within the scope of t.Run(..) below 69 artifactName := artifactName 70 curOptions := curOptions 71 go func() { 72 defer waitForArtifacts.Done() 73 artifactId, err := BuildArtifactE(t, curOptions) 74 75 if err != nil { 76 errorsOccurred = multierror.Append(errorsOccurred, err) 77 } else { 78 artifactNameToArtifactId[artifactName] = artifactId 79 } 80 }() 81 } 82 83 waitForArtifacts.Wait() 84 85 return artifactNameToArtifactId, errorsOccurred.ErrorOrNil() 86 } 87 88 // BuildArtifact builds the given Packer template and return the generated Artifact ID. 89 func BuildArtifact(t testing.TestingT, options *Options) string { 90 artifactID, err := BuildArtifactE(t, options) 91 if err != nil { 92 t.Fatal(err) 93 } 94 return artifactID 95 } 96 97 // BuildArtifactE builds the given Packer template and return the generated Artifact ID. 98 func BuildArtifactE(t testing.TestingT, options *Options) (string, error) { 99 options.Logger.Logf(t, "Running Packer to generate a custom artifact for template %s", options.Template) 100 101 // By default, we download packer plugins to a temporary directory rather than use the global plugin path. 102 // This prevents race conditions when multiple tests are running in parallel and each of them attempt 103 // to download the same plugin at the same time to the global path. 104 // Set DisableTemporaryPluginPath to disable this behavior. 105 if !options.DisableTemporaryPluginPath { 106 // The built-in env variable defining where plugins are downloaded 107 const packerPluginPathEnvVar = "PACKER_PLUGIN_PATH" 108 options.Logger.Logf(t, "Creating a temporary directory for Packer plugins") 109 pluginDir, err := ioutil.TempDir("", "terratest-packer-") 110 require.NoError(t, err) 111 if len(options.Env) == 0 { 112 options.Env = make(map[string]string) 113 } 114 options.Env[packerPluginPathEnvVar] = pluginDir 115 defer os.RemoveAll(pluginDir) 116 } 117 118 err := packerInit(t, options) 119 if err != nil { 120 return "", err 121 } 122 123 cmd := shell.Command{ 124 Command: "packer", 125 Args: formatPackerArgs(options), 126 Env: options.Env, 127 WorkingDir: options.WorkingDir, 128 } 129 130 description := fmt.Sprintf("%s %v", cmd.Command, cmd.Args) 131 output, err := retry.DoWithRetryableErrorsE(t, description, options.RetryableErrors, options.MaxRetries, options.TimeBetweenRetries, func() (string, error) { 132 return shell.RunCommandAndGetOutputE(t, cmd) 133 }) 134 135 if err != nil { 136 return "", err 137 } 138 139 return extractArtifactID(output) 140 } 141 142 // BuildAmi builds the given Packer template and return the generated AMI ID. 143 // 144 // Deprecated: Use BuildArtifact instead. 145 func BuildAmi(t testing.TestingT, options *Options) string { 146 return BuildArtifact(t, options) 147 } 148 149 // BuildAmiE builds the given Packer template and return the generated AMI ID. 150 // 151 // Deprecated: Use BuildArtifactE instead. 152 func BuildAmiE(t testing.TestingT, options *Options) (string, error) { 153 return BuildArtifactE(t, options) 154 } 155 156 // The Packer machine-readable log output should contain an entry of this format: 157 // 158 // AWS: <timestamp>,<builder>,artifact,<index>,id,<region>:<image_id> 159 // GCP: <timestamp>,<builder>,artifact,<index>,id,<image_id> 160 // 161 // For example: 162 // 163 // 1456332887,amazon-ebs,artifact,0,id,us-east-1:ami-b481b3de 164 // 1533742764,googlecompute,artifact,0,id,terratest-packer-example-2018-08-08t15-35-19z 165 func extractArtifactID(packerLogOutput string) (string, error) { 166 re := regexp.MustCompile(`.+artifact,\d+?,id,(?:.+?:|)(.+)`) 167 matches := re.FindStringSubmatch(packerLogOutput) 168 169 if len(matches) == 2 { 170 return matches[1], nil 171 } 172 return "", errors.New("Could not find Artifact ID pattern in Packer output") 173 } 174 175 // Check if the local version of Packer has init 176 func hasPackerInit(t testing.TestingT, options *Options) (bool, error) { 177 // The init command was introduced in Packer 1.7.0 178 const packerInitVersion = "1.7.0" 179 minInitVersion, err := version.NewVersion(packerInitVersion) 180 if err != nil { 181 return false, err 182 } 183 184 cmd := shell.Command{ 185 Command: "packer", 186 Args: []string{"-version"}, 187 Env: options.Env, 188 WorkingDir: options.WorkingDir, 189 } 190 localVersion, err := shell.RunCommandAndGetOutputE(t, cmd) 191 if err != nil { 192 return false, err 193 } 194 thisVersion, err := version.NewVersion(localVersion) 195 if err != nil { 196 return false, err 197 } 198 199 if thisVersion.LessThan(minInitVersion) { 200 return false, nil 201 } 202 203 return true, nil 204 } 205 206 // packerInit runs 'packer init' if it is supported by the local packer 207 func packerInit(t testing.TestingT, options *Options) error { 208 hasInit, err := hasPackerInit(t, options) 209 if err != nil { 210 return err 211 } 212 if !hasInit { 213 options.Logger.Logf(t, "Skipping 'packer init' because it is not present in this version") 214 return nil 215 } 216 217 extension := filepath.Ext(options.Template) 218 if extension != ".hcl" { 219 options.Logger.Logf(t, "Skipping 'packer init' because it is only supported for HCL2 templates") 220 return nil 221 } 222 223 cmd := shell.Command{ 224 Command: "packer", 225 Args: []string{"init", options.Template}, 226 Env: options.Env, 227 WorkingDir: options.WorkingDir, 228 } 229 230 description := "Running Packer init" 231 _, err = retry.DoWithRetryableErrorsE(t, description, options.RetryableErrors, options.MaxRetries, options.TimeBetweenRetries, func() (string, error) { 232 return shell.RunCommandAndGetOutputE(t, cmd) 233 }) 234 235 if err != nil { 236 return err 237 } 238 239 return nil 240 } 241 242 // Convert the inputs to a format palatable to packer. The build command should have the format: 243 // 244 // packer build [OPTIONS] template 245 func formatPackerArgs(options *Options) []string { 246 args := []string{"build", "-machine-readable"} 247 248 for key, value := range options.Vars { 249 args = append(args, "-var", fmt.Sprintf("%s=%s", key, value)) 250 } 251 252 for _, filePath := range options.VarFiles { 253 args = append(args, "-var-file", filePath) 254 } 255 256 if options.Only != "" { 257 args = append(args, fmt.Sprintf("-only=%s", options.Only)) 258 } 259 260 if options.Except != "" { 261 args = append(args, fmt.Sprintf("-except=%s", options.Except)) 262 } 263 264 return append(args, options.Template) 265 }