Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTPConfig : Add http_content to be served #43

Merged
merged 20 commits into from
Mar 23, 2021
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the package name!


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"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a backward incompatibility but it is something that will require all builder components using http_config to update their hcl2spec files in order to be compatible with this feature.
If I'm right, I have two questions:

  • Are you planning to write a blog post for this?
  • Should this be officially released on 1.8.0?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good questions!

Should this be officially released on 1.8.0?

The earlier we merge this one, the less manual updates we will have to do in plugins in order to support this. So my guess would be to merge this one now-ish.

Are you planning to write a blog post for this?

🤔 I wasn't planning on doing that ! But you are right !
This definitely could use a new guide or help section too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! We just need to remember to run generate on Packer when this one is merged. So that 1.7.1 will be compatible with these changes.

// 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"))
azr marked this conversation as resolved.
Show resolved Hide resolved
}

return errs
}
70 changes: 64 additions & 6 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)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes lives easier <3 !

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

( even saved me a few times in tests mode already )

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is super cool :D


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 @@ -70,6 +126,8 @@ func (s *StepHTTPServer) Run(ctx context.Context, state multistep.StateBag) mult
func (s *StepHTTPServer) Cleanup(multistep.StateBag) {
if s.l != nil {
// Close the listener so that the HTTP server stops
s.l.Close()
if err := s.l.Close(); err != nil {
log.Printf("Failed closing http server on port %d: %v", s.l.Port, err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this error print to the UI?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, this error should be temporary and won't prevent Packer from working. We check if ports are already open for new calls to Listen. Finally, when Packer terminates: the connection will be auto closed on most systems. Though on some system it could take some time and cause a FS leak if it happened A LOT of times. Yup this should be at least be shown in the UI.

}
}
}
86 changes: 86 additions & 0 deletions multistep/commonsteps/step_http_server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//go:generate mapstructure-to-hcl2 -type TestHttpConfig

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