diff --git a/docs/provisioners/webservice-repository.mdx b/docs/provisioners/webservice-repository.mdx new file mode 100644 index 0000000..7258dfd --- /dev/null +++ b/docs/provisioners/webservice-repository.mdx @@ -0,0 +1,85 @@ +Type: `webservice` + + + +The `webservice` provisioner is used to install Jersey-Jetty webservice WAR file in AWS AMI image + + + + +**Required** + +- `warSource` (string) - The path to a local WAR file to upload to the machine. The path can be absolute or relative. If + it is relative, it is relative to the working directory when Packer is executed. + + + + +**Optional** + +- `homeDir` (string) - The `$Home` directory in AMI image; default to `/home/ubuntu` + + + +### Example Usage + +```hcl +packer { + required_plugins { + amazon = { + version = ">= 0.0.2" + source = "github.com/hashicorp/amazon" + } + } +} + +source "amazon-ebs" "hashicorp-aws" { + ami_name = "packer-plugin-hashicorp-aws-acc-test-ami" + force_deregister = "true" + force_delete_snapshot = "true" + + instance_type = "t2.micro" + launch_block_device_mappings { + device_name = "/dev/sda1" + volume_size = 8 + volume_type = "gp2" + delete_on_termination = true + } + region = "us-west-1" + source_ami_filter { + filters = { + name = "ubuntu/images/*ubuntu-*-22.04-amd64-server-*" + root-device-type = "ebs" + virtualization-type = "hvm" + } + most_recent = true + owners = ["099720109477"] + } + ssh_username = "ubuntu" +} + +build { + sources = [ + "source.amazon-ebs.hashicorp-aws" + ] + + provisioner "hashicorp-aws-webservice-provisioner" { + homeDir = "/home/ubuntu" + warSource = "my-webservice.war" + } +} +``` diff --git a/main.go b/main.go index d9dc37d..b32b9cf 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ package main import ( "fmt" sonatypeNexusRepository "github.com/QubitPi/packer-plugin-hashicorp-aws/provisioner/sonatype-nexus-repository" + "github.com/QubitPi/packer-plugin-hashicorp-aws/provisioner/webservice" "os" dockerMailServerProv "github.com/QubitPi/packer-plugin-hashicorp-aws/provisioner/docker-mailserver" @@ -20,6 +21,7 @@ func main() { pps.RegisterProvisioner("docker-mailserver-provisioner", new(dockerMailServerProv.Provisioner)) pps.RegisterProvisioner("kong-api-gateway-provisioner", new(kongApiGatewayProv.Provisioner)) pps.RegisterProvisioner("sonatype-nexus-repository-provisioner", new(sonatypeNexusRepository.Provisioner)) + pps.RegisterProvisioner("webservice-provisioner", new(webservice.Provisioner)) pps.SetVersion(pluginVersion.PluginVersion) err := pps.Run() if err != nil { diff --git a/provisioner/basic-provisioner/basic-provisioner.go b/provisioner/basic-provisioner/basic-provisioner.go index b0fbc8d..dd3d69b 100644 --- a/provisioner/basic-provisioner/basic-provisioner.go +++ b/provisioner/basic-provisioner/basic-provisioner.go @@ -2,23 +2,98 @@ // SPDX-License-Identifier: MPL-2.0 // This package implements a provisioner for Packer that executes a specified list of shell commands within the remote -// machine +// machine. It doesn't use Packer's original shell provisioner +// (https://github.com/hashicorp/packer/blob/main/provisioner/shell/provisioner.go), which is essentially not fully +// exported for public use due to the unexported config parameter - +// https://github.com/hashicorp/packer/blob/1e446de977e93b7119ebbaa6f55268bd29240e4f/provisioner/shell/provisioner.go#L73 +// This parameter is unexported because is not capitalized +// +// This provisioner works by loading all provided amiConfigCommands into a shell scripts. We do this because executing +// amiConfigCommands separately in the following way simply doesn't work: +// +// if len(amiConfigCommands) > 0 { +// for _, command := range amiConfigCommands { +// err := (&packersdk.RemoteCmd{Command: command}).RunWithUi(ctx, communicator, ui) +// if err != nil { +// return fmt.Errorf("CMD error: %s", err) +// } +// } +// } +// +// because each command is executed in separate shell, meaning their state is not preserved unless the state is written +// to the remote machines hard disk. For example, the regular env variable export like "export JAVA_HOME=..." won't +// carry over to the next command. The only way to preserve all in-memory states is to run everything in a one-time +// script, which is how this provisioner is implemented package basicProvisioner import ( + "bufio" "context" + "fmt" packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/hashicorp/packer-plugin-sdk/retry" + "github.com/hashicorp/packer-plugin-sdk/tmp" + "math/rand" + "os" ) func Provision(ctx context.Context, ui packersdk.Ui, communicator packersdk.Communicator, amiConfigCommands []string) error { - if len(amiConfigCommands) > 0 { - for _, command := range amiConfigCommands { - err := (&packersdk.RemoteCmd{Command: command}).RunWithUi(ctx, communicator, ui) - if err != nil { - return err - } + scriptFile, err := tmp.File("packer-shell") + if err != nil { + return fmt.Errorf("Error while trying to load commands into a shell script: %s", err) + } + defer os.Remove(scriptFile.Name()) + + writer := bufio.NewWriter(scriptFile) + writer.WriteString("#!/bin/bash\n") + writer.WriteString("set -x\n") + writer.WriteString("set -e\n") + writer.WriteString("\n") + for _, command := range amiConfigCommands { + if _, err := writer.WriteString(command + "\n"); err != nil { + return fmt.Errorf("Error flushing command '%s' into a shell script: %s", command, err) } } + if err := writer.Flush(); err != nil { + return fmt.Errorf("Error flushing shell script: %s", err) + } + + scriptFile.Close() + + ui.Say(fmt.Sprintf("Provisioning with %s", amiConfigCommands)) + + f, err := os.Open(scriptFile.Name()) + if err != nil { + return fmt.Errorf("Error opening shell script: %s", err) + } + defer f.Close() + + var cmd *packersdk.RemoteCmd + err = retry.Config{}.Run(ctx, func(ctx context.Context) error { + if _, err := f.Seek(0, 0); err != nil { + return err + } + + remotePath := fmt.Sprintf("%s/%s", "/tmp", fmt.Sprintf("script_%d.sh", rand.Intn(9999))) + if err := communicator.Upload(remotePath, f, nil); err != nil { + return fmt.Errorf("Error uploading script: %s", err) + } + + cmd = &packersdk.RemoteCmd{ + Command: fmt.Sprintf("chmod 0755 %s", remotePath), + } + if err := communicator.Start(ctx, cmd); err != nil { + return fmt.Errorf("Error chmodding script file to 0755 in remote machine: %s", err) + } + cmd.Wait() + + cmd = &packersdk.RemoteCmd{Command: fmt.Sprintf("chmod +x %s; %s", remotePath, remotePath)} + return cmd.RunWithUi(ctx, communicator, ui) + }) + + if err != nil { + return err + } return nil } diff --git a/provisioner/sonatype-nexus-repository/provisioner_acc_test.go b/provisioner/sonatype-nexus-repository/provisioner_acc_test.go index f3ce246..6a7f6ef 100644 --- a/provisioner/sonatype-nexus-repository/provisioner_acc_test.go +++ b/provisioner/sonatype-nexus-repository/provisioner_acc_test.go @@ -48,7 +48,10 @@ func TestAccSonatypeNexusRepositoryProvisioner(t *testing.T) { } logsString := string(logsBytes) - t.Log(logsBytes) + errorString := "\\[ERROR\\] Remote command exited with" + if matched, _ := regexp.MatchString(".*"+errorString+".*", logsString); matched { + t.Fatalf("Acceptance tests for %s failed. Please search for '%s' in log file at %s", "sonatype-nexus-repository provisioner", errorString, logfile) + } provisionerOutputLog := "amazon-ebs.hashicorp-aws: AMIs were created:" if matched, _ := regexp.MatchString(provisionerOutputLog+".*", logsString); !matched { diff --git a/provisioner/sonatype-nexus-repository/test-fixtures/template.pkr.hcl b/provisioner/sonatype-nexus-repository/test-fixtures/template.pkr.hcl index 425b895..60be993 100644 --- a/provisioner/sonatype-nexus-repository/test-fixtures/template.pkr.hcl +++ b/provisioner/sonatype-nexus-repository/test-fixtures/template.pkr.hcl @@ -11,7 +11,7 @@ packer { } source "amazon-ebs" "hashicorp-aws" { - ami_name = "packer-plugin-hashicorp-aws-acc-test-ami" + ami_name = "packer-plugin-hashicorp-aws-acc-test-ami-sonatype-nexus-repository" force_deregister = "true" force_delete_snapshot = "true" diff --git a/provisioner/ssl-provisioner/ssl-provisioner.go b/provisioner/ssl-provisioner/ssl-provisioner.go index 36ef23f..4125df1 100644 --- a/provisioner/ssl-provisioner/ssl-provisioner.go +++ b/provisioner/ssl-provisioner/ssl-provisioner.go @@ -66,7 +66,7 @@ func Provision(ctx context.Context, interCtx interpolate.Context, ui packersdk.U sslCertKey, err := decodeBase64(sslCertKeyBase64) sslCertKeySource, err := WriteToFile(sslCertKey) sslCertKeyDestination := fmt.Sprintf(filepath.Join(homeDir, "ssl.key")) - err = fileProvisioner.Provision(interCtx, ui, communicator, sslCertSource, sslCertDestination) + err = fileProvisioner.Provision(interCtx, ui, communicator, sslCertKeySource, sslCertKeyDestination) if err != nil { return fmt.Errorf("error uploading '%s' to '%s': %s", sslCertKeySource, sslCertKeyDestination, err) } @@ -74,7 +74,7 @@ func Provision(ctx context.Context, interCtx interpolate.Context, ui packersdk.U if nginxConfig != "" { nginxSource, err := WriteToFile(nginxConfig) nginxDst := fmt.Sprintf(filepath.Join(homeDir, "nginx-ssl.conf")) - err = fileProvisioner.Provision(interCtx, ui, communicator, sslCertSource, sslCertDestination) + err = fileProvisioner.Provision(interCtx, ui, communicator, nginxSource, nginxDst) if err != nil { return fmt.Errorf("error uploading '%s' to '%s': %s", nginxSource, nginxDst, err) } diff --git a/provisioner/webservice/provisioner.go b/provisioner/webservice/provisioner.go new file mode 100644 index 0000000..59fec46 --- /dev/null +++ b/provisioner/webservice/provisioner.go @@ -0,0 +1,95 @@ +// Copyright (c) Jiaqi Liu +// SPDX-License-Identifier: MPL-2.0 + +//go:generate packer-sdc mapstructure-to-hcl2 -type Config + +package webservice + +import ( + "context" + "fmt" + basicProvisioner "github.com/QubitPi/packer-plugin-hashicorp-aws/provisioner/basic-provisioner" + fileProvisioner "github.com/QubitPi/packer-plugin-hashicorp-aws/provisioner/file-provisioner" + sslProvisioner "github.com/QubitPi/packer-plugin-hashicorp-aws/provisioner/ssl-provisioner" + "github.com/hashicorp/hcl/v2/hcldec" + packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/hashicorp/packer-plugin-sdk/template/config" + "github.com/hashicorp/packer-plugin-sdk/template/interpolate" + "path/filepath" +) + +type Config struct { + WarSource string `mapstructure:"warSource" required:"true"` + HomeDir string `mapstructure:"homeDir" required:"false"` + + ctx interpolate.Context +} + +type Provisioner struct { + config Config +} + +func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { + return p.config.FlatMapstructure().HCL2Spec() +} + +func (p *Provisioner) Prepare(raws ...interface{}) error { + err := config.Decode(&p.config, nil, raws...) + if err != nil { + return err + } + + return nil +} + +func (p *Provisioner) Provision(ctx context.Context, ui packersdk.Ui, communicator packersdk.Communicator, generatedData map[string]interface{}) error { + p.config.HomeDir = sslProvisioner.GetHomeDir(p.config.HomeDir) + + warFileDst := fmt.Sprintf(filepath.Join(p.config.HomeDir, "ROOT.war")) + + err := fileProvisioner.Provision(p.config.ctx, ui, communicator, p.config.WarSource, warFileDst) + if err != nil { + return err + } + + return basicProvisioner.Provision(ctx, ui, communicator, getCommands(p.config.HomeDir)) +} + +func getCommands(homeDir string) []string { + return append( + getCommandsUpdatingUbuntu(), + append(getCommandsInstallingJDK17(), getCommandsInstallingJetty(homeDir)...)..., + ) +} + +func getCommandsUpdatingUbuntu() []string { + return []string{ + "sudo apt update && sudo apt upgrade -y", + "sudo apt install software-properties-common -y", + } +} + +// Install JDK 17 - https://www.rosehosting.com/blog/how-to-install-java-17-lts-on-ubuntu-20-04/ +func getCommandsInstallingJDK17() []string { + return []string{ + "sudo apt update -y", + "sudo apt install openjdk-17-jdk -y", + "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64", + } +} + +// Install and configure Jetty (for JDK 17) container +func getCommandsInstallingJetty(homeDir string) []string { + return []string{ + "export JETTY_VERSION=11.0.15", + "wget https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-home/$JETTY_VERSION/jetty-home-$JETTY_VERSION.tar.gz", + "tar -xzvf jetty-home-$JETTY_VERSION.tar.gz", + "rm jetty-home-$JETTY_VERSION.tar.gz", + fmt.Sprintf("export JETTY_HOME=%s/jetty-home-$JETTY_VERSION", homeDir), + "mkdir jetty-base", + "cd jetty-base", + "java -jar $JETTY_HOME/start.jar --add-module=annotations,server,http,deploy,servlet,webapp,resources,jsp", + fmt.Sprintf("mv %s/ROOT.war webapps/ROOT.war", homeDir), + "cd ../", + } +} diff --git a/provisioner/webservice/provisioner.hcl2spec.go b/provisioner/webservice/provisioner.hcl2spec.go new file mode 100644 index 0000000..db222c4 --- /dev/null +++ b/provisioner/webservice/provisioner.hcl2spec.go @@ -0,0 +1,33 @@ +// Code generated by "packer-sdc mapstructure-to-hcl2"; DO NOT EDIT. + +package webservice + +import ( + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/zclconf/go-cty/cty" +) + +// FlatConfig is an auto-generated flat version of Config. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatConfig struct { + WarSource *string `mapstructure:"warSource" required:"true" cty:"warSource" hcl:"warSource"` + HomeDir *string `mapstructure:"homeDir" required:"false" cty:"homeDir" hcl:"homeDir"` +} + +// FlatMapstructure returns a new FlatConfig. +// FlatConfig is an auto-generated flat version of Config. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*Config) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatConfig) +} + +// HCL2Spec returns the hcl spec of a Config. +// This spec is used by HCL to read the fields of Config. +// The decoded values from this spec will then be applied to a FlatConfig. +func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "warSource": &hcldec.AttrSpec{Name: "warSource", Type: cty.String, Required: false}, + "homeDir": &hcldec.AttrSpec{Name: "homeDir", Type: cty.String, Required: false}, + } + return s +} diff --git a/provisioner/webservice/provisioner_acc_test.go b/provisioner/webservice/provisioner_acc_test.go new file mode 100644 index 0000000..56b06f8 --- /dev/null +++ b/provisioner/webservice/provisioner_acc_test.go @@ -0,0 +1,71 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package webservice + +import ( + _ "embed" + "fmt" + "io/ioutil" + "os" + "os/exec" + "regexp" + "strings" + "testing" + + "github.com/hashicorp/packer-plugin-sdk/acctest" +) + +//go:embed test-fixtures/template.pkr.hcl +var testProvisionerHCL2Basic string + +func TestAccWebserviceProvisioner(t *testing.T) { + tempFile, err := os.CreateTemp(t.TempDir(), "my-webservice.war") + if err != nil { + return + } + + testCase := &acctest.PluginTestCase{ + Name: "webservice_provisioner_basic_test", + Setup: func() error { + return nil + }, + Teardown: func() error { + return nil + }, + Template: strings.Replace(testProvisionerHCL2Basic, "my-webservice.war", tempFile.Name(), -1), + Type: "hashicorp-aws-webservice-provisioner", + Check: func(buildCommand *exec.Cmd, logfile string) error { + if buildCommand.ProcessState != nil { + if buildCommand.ProcessState.ExitCode() != 0 { + return fmt.Errorf("Bad exit code. Logfile: %s", logfile) + } + } + + logs, err := os.Open(logfile) + if err != nil { + return fmt.Errorf("Unable find %s", logfile) + } + defer logs.Close() + + logsBytes, err := ioutil.ReadAll(logs) + if err != nil { + return fmt.Errorf("Unable to read %s", logfile) + } + logsString := string(logsBytes) + + errorString := "\\[ERROR\\] Remote command exited with" + if matched, _ := regexp.MatchString(".*"+errorString+".*", logsString); matched { + t.Fatalf("Acceptance tests for %s failed. Please search for '%s' in log file at %s", "webservice provisioner", errorString, logfile) + } + + provisionerOutputLog := "amazon-ebs.hashicorp-aws: AMIs were created:" + if matched, _ := regexp.MatchString(provisionerOutputLog+".*", logsString); !matched { + t.Fatalf("logs doesn't contain expected output %q", logsString) + } + + return nil + }, + } + acctest.TestPlugin(t, testCase) +} diff --git a/provisioner/webservice/test-fixtures/template.pkr.hcl b/provisioner/webservice/test-fixtures/template.pkr.hcl new file mode 100644 index 0000000..0bcfd2c --- /dev/null +++ b/provisioner/webservice/test-fixtures/template.pkr.hcl @@ -0,0 +1,47 @@ +# Copyright (c) Jiaqi +# SPDX-License-Identifier: MPL-2.0 + +packer { + required_plugins { + amazon = { + version = ">= 0.0.2" + source = "github.com/hashicorp/amazon" + } + } +} + +source "amazon-ebs" "hashicorp-aws" { + ami_name = "packer-plugin-hashicorp-aws-acc-test-ami-webservice" + force_deregister = "true" + force_delete_snapshot = "true" + + instance_type = "t2.micro" + launch_block_device_mappings { + device_name = "/dev/sda1" + volume_size = 8 + volume_type = "gp2" + delete_on_termination = true + } + region = "us-west-1" + source_ami_filter { + filters = { + name = "ubuntu/images/*ubuntu-*-22.04-amd64-server-*" + root-device-type = "ebs" + virtualization-type = "hvm" + } + most_recent = true + owners = ["099720109477"] + } + ssh_username = "ubuntu" +} + +build { + sources = [ + "source.amazon-ebs.hashicorp-aws" + ] + + provisioner "hashicorp-aws-webservice-provisioner" { + homeDir = "/home/ubuntu" + warSource = "my-webservice.war" + } +}