Skip to content

Commit

Permalink
HTTPConfig : Add http_content to be served (hashicorp#43)
Browse files Browse the repository at this point in the history
* test
* docs
  • Loading branch information
azr committed Mar 23, 2021
1 parent 4b7031f commit 5a7ab7f
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 7 deletions.
24 changes: 24 additions & 0 deletions didyoumean/name_suggestion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package didyoumean

import (
"github.com/agext/levenshtein"
)

// NameSuggestion tries to find a name from the given slice of suggested names
// that is close to the given name and returns it if found. If no suggestion is
// close enough, returns the empty string.
//
// The suggestions are tried in order, so earlier suggestions take precedence if
// the given string is similar to two or more suggestions.
//
// This function is intended to be used with a relatively-small number of
// suggestions. It's not optimized for hundreds or thousands of them.
func NameSuggestion(given string, suggestions []string) string {
for _, suggestion := range suggestions {
dist := levenshtein.Distance(given, suggestion, nil)
if dist < 3 { // threshold determined experimentally
return suggestion
}
}
return ""
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module github.com/hashicorp/packer-plugin-sdk

require (
github.com/agext/levenshtein v1.2.1
github.com/aws/aws-sdk-go v1.36.5
github.com/dylanmei/winrmtest v0.0.0-20170819153634-c2fbb09e6c08
github.com/fatih/camelcase v1.0.0
Expand Down
14 changes: 14 additions & 0 deletions multistep/commonsteps/http_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ type HTTPConfig struct {
// started. The address and port of the HTTP server will be available as
// variables in `boot_command`. This is covered in more detail below.
HTTPDir string `mapstructure:"http_directory"`
// Key/Values to serve using an HTTP server. http_content works like and
// conflicts with http_directory The keys represent the paths and the values
// contents. This is useful for hosting kickstart files and so on. By
// default this is empty, which means no HTTP server will be started. The
// address and port of the HTTP server will be available as variables in
// `boot_command`. This is covered in more detail below. Example: Setting
// `"foo/bar"="baz"`, will allow you to http get on
// `http://{http_ip}:{http_port}/foo/bar`.
HTTPContent map[string]string `mapstructure:"http_content"`
// These are the minimum and maximum port to use for the HTTP server
// started to serve the `http_directory`. Because Packer often runs in
// parallel, Packer will choose a randomly available port in this range to
Expand Down Expand Up @@ -66,5 +75,10 @@ func (c *HTTPConfig) Prepare(ctx *interpolate.Context) []error {
errors.New("http_port_min must be less than http_port_max"))
}

if len(c.HTTPContent) > 0 && len(c.HTTPDir) > 0 {
errs = append(errs,
errors.New("http_content cannot be used in conjunction with http_dir. Consider using the file function to load file in memory and serve them with http_content: https://www.packer.io/docs/templates/hcl_templates/functions/file/file"))
}

return errs
}
81 changes: 74 additions & 7 deletions multistep/commonsteps/step_http_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,28 @@ package commonsteps
import (
"context"
"fmt"

"log"
"net/http"
"os"
"path"
"sort"

"github.com/hashicorp/packer-plugin-sdk/didyoumean"
"github.com/hashicorp/packer-plugin-sdk/multistep"
"github.com/hashicorp/packer-plugin-sdk/net"
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
)

func HTTPServerFromHTTPConfig(cfg *HTTPConfig) *StepHTTPServer {
return &StepHTTPServer{
HTTPDir: cfg.HTTPDir,
HTTPContent: cfg.HTTPContent,
HTTPPortMin: cfg.HTTPPortMin,
HTTPPortMax: cfg.HTTPPortMax,
HTTPAddress: cfg.HTTPAddress,
}
}

// This step creates and runs the HTTP server that is serving files from the
// directory specified by the 'http_directory` configuration parameter in the
// template.
Expand All @@ -22,23 +36,66 @@ import (
// http_port int - The port the HTTP server started on.
type StepHTTPServer struct {
HTTPDir string
HTTPContent map[string]string
HTTPPortMin int
HTTPPortMax int
HTTPAddress string

l *net.Listener
}

func (s *StepHTTPServer) Handler() http.Handler {
if s.HTTPDir != "" {
return http.FileServer(http.Dir(s.HTTPDir))
}

return MapServer(s.HTTPContent)
}

type MapServer map[string]string

func (s MapServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := path.Clean(r.URL.Path)
content, found := s[path]
if !found {
paths := make([]string, 0, len(s))
for k := range s {
paths = append(paths, k)
}
sort.Strings(paths)
err := fmt.Sprintf("%s not found.", path)
if sug := didyoumean.NameSuggestion(path, paths); sug != "" {
err += fmt.Sprintf(" Did you mean %q?", sug)
}

http.Error(w, err, http.StatusNotFound)
return
}

if _, err := w.Write([]byte(content)); err != nil {
// log err in case the file couldn't be 100% transferred for example.
log.Printf("http_content serve error: %v", err)
}
}

func (s *StepHTTPServer) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction {
ui := state.Get("ui").(packersdk.Ui)

if s.HTTPDir == "" {
if s.HTTPDir == "" && len(s.HTTPContent) == 0 {
state.Put("http_port", 0)
return multistep.ActionContinue
}

if s.HTTPDir != "" {
if _, err := os.Stat(s.HTTPDir); err != nil {
err := fmt.Errorf("Error finding %q: %s", s.HTTPDir, err)
state.Put("error", err)
ui.Error(err.Error())
return multistep.ActionHalt
}
}

// Find an available TCP port for our HTTP server
var httpAddr string
var err error
s.l, err = net.ListenRangeConfig{
Min: s.HTTPPortMin,
Expand All @@ -57,8 +114,7 @@ func (s *StepHTTPServer) Run(ctx context.Context, state multistep.StateBag) mult
ui.Say(fmt.Sprintf("Starting HTTP server on port %d", s.l.Port))

// Start the HTTP server and run it in the background
fileServer := http.FileServer(http.Dir(s.HTTPDir))
server := &http.Server{Addr: httpAddr, Handler: fileServer}
server := &http.Server{Addr: "", Handler: s.Handler()}
go server.Serve(s.l)

// Save the address into the state so it can be accessed in the future
Expand All @@ -67,9 +123,20 @@ func (s *StepHTTPServer) Run(ctx context.Context, state multistep.StateBag) mult
return multistep.ActionContinue
}

func (s *StepHTTPServer) Cleanup(multistep.StateBag) {
func (s *StepHTTPServer) Cleanup(state multistep.StateBag) {
if s.l != nil {
ui := state.Get("ui").(packersdk.Ui)

// Close the listener so that the HTTP server stops
s.l.Close()
if err := s.l.Close(); err != nil {
err = fmt.Errorf("Failed closing http server on port %d: %w", s.l.Port, err)
ui.Error(err.Error())
// Here this error should be shown to the UI but it won't
// specifically stop Packer from terminating successfully. It could
// cause a "Listen leak" if it happenned a lot. Though Listen will
// try other ports if one is already used. In the case we want to
// Listen on only one port, the next Listen call could fail or be
// longer than expected.
}
}
}
84 changes: 84 additions & 0 deletions multistep/commonsteps/step_http_server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package commonsteps

import (
"context"
"fmt"
"io/ioutil"
"net/http"
"reflect"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/hashicorp/packer-plugin-sdk/multistep"
)

func TestStepHTTPServer_Run(t *testing.T) {

tests := []struct {
cfg *HTTPConfig
want multistep.StepAction
wantPort interface{}
wantContent map[string]string
}{
{
&HTTPConfig{},
multistep.ActionContinue,
0,
nil,
},
{
&HTTPConfig{HTTPDir: "unknown_folder"},
multistep.ActionHalt,
nil,
nil,
},
{
&HTTPConfig{HTTPDir: "test-fixtures", HTTPPortMin: 9000},
multistep.ActionContinue,
9000,
map[string]string{
"SomeDir/myfile.txt": "",
},
},
{
&HTTPConfig{HTTPContent: map[string]string{"/foo.txt": "biz", "/foo/bar.txt": "baz"}, HTTPPortMin: 9001},
multistep.ActionContinue,
9001,
map[string]string{
"foo.txt": "biz",
"/foo.txt": "biz",
"foo/bar.txt": "baz",
"/foo/bar.txt": "baz",
},
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%#v", tt.cfg), func(t *testing.T) {
s := HTTPServerFromHTTPConfig(tt.cfg)
state := testState(t)
got := s.Run(context.Background(), state)
defer s.Cleanup(state)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("StepHTTPServer.Run() = %s, want %s", got, tt.want)
}
gotPort := state.Get("http_port")
if !reflect.DeepEqual(gotPort, tt.wantPort) {
t.Errorf("StepHTTPServer.Run() unexpected port = %v, want %v", gotPort, tt.wantPort)
}
for k, wantResponse := range tt.wantContent {
resp, err := http.Get(fmt.Sprintf("http://:%d/%s", gotPort, k))
if err != nil {
t.Fatalf("http.Get: %v", err)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatalf("readall: %v", err)
}
gotResponse := string(b)
if diff := cmp.Diff(wantResponse, gotResponse); diff != "" {
t.Fatalf("Unexpected %q content: %s", k, diff)
}
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
started. The address and port of the HTTP server will be available as
variables in `boot_command`. This is covered in more detail below.

- `http_content` (map[string]string) - Key/Values to serve using an HTTP server. http_content works like and
conflicts with http_directory The keys represent the paths and the values
contents. This is useful for hosting kickstart files and so on. By
default this is empty, which means no HTTP server will be started. The
address and port of the HTTP server will be available as variables in
`boot_command`. This is covered in more detail below. Example: Setting
`"foo/bar"="baz"`, will allow you to http get on
`http://{http_ip}:{http_port}/foo/bar`.

- `http_port_min` (int) - These are the minimum and maximum port to use for the HTTP server
started to serve the `http_directory`. Because Packer often runs in
parallel, Packer will choose a randomly available port in this range to
Expand Down

0 comments on commit 5a7ab7f

Please sign in to comment.