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

feat: add http datasource #11658

Merged
merged 13 commits into from
May 17, 2022
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions command/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
nullbuilder "github.com/hashicorp/packer/builder/null"
hcppackerimagedatasource "github.com/hashicorp/packer/datasource/hcp-packer-image"
hcppackeriterationdatasource "github.com/hashicorp/packer/datasource/hcp-packer-iteration"
httpdatasource "github.com/hashicorp/packer/datasource/http"
nulldatasource "github.com/hashicorp/packer/datasource/null"
artificepostprocessor "github.com/hashicorp/packer/post-processor/artifice"
checksumpostprocessor "github.com/hashicorp/packer/post-processor/checksum"
Expand Down Expand Up @@ -64,6 +65,7 @@ var PostProcessors = map[string]packersdk.PostProcessor{
var Datasources = map[string]packersdk.Datasource{
"hcp-packer-image": new(hcppackerimagedatasource.Datasource),
"hcp-packer-iteration": new(hcppackeriterationdatasource.Datasource),
"http": new(httpdatasource.Datasource),
"null": new(nulldatasource.Datasource),
}

Expand Down
153 changes: 153 additions & 0 deletions datasource/http/data.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//go:generate packer-sdc struct-markdown
//go:generate packer-sdc mapstructure-to-hcl2 -type DatasourceOutput,Config
package http

import (
"context"
"fmt"
"io/ioutil"
"mime"
"net/http"
"regexp"
"strings"

"github.com/hashicorp/hcl/v2/hcldec"
"github.com/hashicorp/packer-plugin-sdk/common"
"github.com/hashicorp/packer-plugin-sdk/hcl2helper"
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
"github.com/hashicorp/packer-plugin-sdk/template/config"
"github.com/zclconf/go-cty/cty"
)

type Config struct {
common.PackerConfig `mapstructure:",squash"`
// Url where should be getting things from
teddylear marked this conversation as resolved.
Show resolved Hide resolved
Url string `mapstructure:"url" required:"true"`
// Request headers for call
teddylear marked this conversation as resolved.
Show resolved Hide resolved
Request_headers map[string]string `mapstructure:"request_headers" required:"false"`
}

type Datasource struct {
config Config
}

type DatasourceOutput struct {
Url string `mapstructure:"url"`
Response_body string `mapstructure:"body"`
Response_headers map[string]string `mapstructure:"request_headers"`
teddylear marked this conversation as resolved.
Show resolved Hide resolved
}

func (d *Datasource) ConfigSpec() hcldec.ObjectSpec {
return d.config.FlatMapstructure().HCL2Spec()
}

func (d *Datasource) Configure(raws ...interface{}) error {
err := config.Decode(&d.config, nil, raws...)
if err != nil {
return err
}

var errs *packersdk.MultiError

if d.config.Url == "" {
errs = packersdk.MultiErrorAppend(
errs,
fmt.Errorf("the `url` must be specified"))
}

if errs != nil && len(errs.Errors) > 0 {
return errs
}
return nil
}

func (d *Datasource) OutputSpec() hcldec.ObjectSpec {
return (&DatasourceOutput{}).FlatMapstructure().HCL2Spec()
}

// This is to prevent potential issues w/ binary files
// and generally unprintable characters
// See https://github.com/hashicorp/terraform/pull/3858#issuecomment-156856738
func isContentTypeText(contentType string) bool {

parsedType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return false
}

allowedContentTypes := []*regexp.Regexp{
regexp.MustCompile("^text/.+"),
regexp.MustCompile("^application/json$"),
regexp.MustCompile("^application/samlmetadata\\+xml"),
}

for _, r := range allowedContentTypes {
if r.MatchString(parsedType) {
charset := strings.ToLower(params["charset"])
return charset == "" || charset == "utf-8" || charset == "us-ascii"
}
}

return false
}

// Most of this code comes from http terraform provider data source
// https://github.com/hashicorp/terraform-provider-http/blob/main/internal/provider/data_source.go
func (d *Datasource) Execute() (cty.Value, error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The returned error message can be improved a little bit to help understand the step it failed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added some print statements before throwing errors to make them more traceable. But I assume we still want to throw current error up the chain.

ctx := context.TODO()
url, headers := d.config.Url, d.config.Request_headers
client := &http.Client{}

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
// TODO: How to make a test case for this?
if err != nil {
fmt.Println("Error creating http request")
return cty.NullVal(cty.EmptyObject), err
}
Comment on lines +105 to +110
Copy link
Contributor

Choose a reason for hiding this comment

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

I shouldn't be here, don't tell anyone, but the httptest library should help here: https://pkg.go.dev/net/http/httptest#NewServer, you can make it return actual data without starting a server, I think.

Also, NewRequestWithContext uses a global client, and I'd recommend on having a more local one.

🥷

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can make a PR with this update. Thanks!


for name, value := range headers {
req.Header.Set(name, value)
}

resp, err := client.Do(req)
// TODO: How to make test case for this
if err != nil {
fmt.Println("Error making performing http request")
return cty.NullVal(cty.EmptyObject), err
}

defer resp.Body.Close()

if resp.StatusCode != 200 {
return cty.NullVal(cty.EmptyObject), fmt.Errorf("HTTP request error. Response code: %d", resp.StatusCode)
}

contentType := resp.Header.Get("Content-Type")
if contentType == "" || isContentTypeText(contentType) == false {
fmt.Println(fmt.Sprintf(
"Content-Type is not recognized as a text type, got %q",
contentType))
fmt.Println("If the content is binary data, Packer may not properly handle the contents of the response.")
}

bytes, err := ioutil.ReadAll(resp.Body)
// TODO: How to make test case for this?
if err != nil {
fmt.Println("Error processing response body of call")
return cty.NullVal(cty.EmptyObject), err
}

responseHeaders := make(map[string]string)
for k, v := range resp.Header {
// Concatenate according to RFC2616
// cf. https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2
responseHeaders[k] = strings.Join(v, ", ")
}

output := DatasourceOutput{
Url: d.config.Url,
Response_headers: responseHeaders,
Response_body: string(bytes),
}
return hcl2helper.HCL2ValueFromConfig(output, d.OutputSpec()), nil
}
76 changes: 76 additions & 0 deletions datasource/http/data.hcl2spec.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 109 additions & 0 deletions datasource/http/data_acc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package http

import (
_ "embed"
"fmt"
"io/ioutil"
"os"
"os/exec"
"regexp"
"testing"

"github.com/hashicorp/packer-plugin-sdk/acctest"
)

//go:embed test-fixtures/basic.pkr.hcl
var testDatasourceBasic string

//go:embed test-fixtures/empty_url.pkr.hcl
var testDatasourceEmptyUrl string

//go:embed test-fixtures/404_url.pkr.hcl
var testDatasource404Url string

func TestHttpDataSource(t *testing.T) {
tests := []struct {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice!

Copy link
Contributor Author

@teddylear teddylear May 7, 2022

Choose a reason for hiding this comment

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

Thanks 😄

Name string
Path string
Error bool
Outputs map[string]string
}{
{
Name: "basic_test",
Path: testDatasourceBasic,
Error: false,
Outputs: map[string]string{
"url": "url is https://www.packer.io/",
// Check that body is not empty
"body": "body is true",
},
},
{
Name: "url_is_empty",
Path: testDatasourceEmptyUrl,
Error: true,
Outputs: map[string]string{
"error": "the `url` must be specified",
},
},
{
Name: "404_url",
Path: testDatasource404Url,
Error: true,
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
testCase := &acctest.PluginTestCase{
Name: tt.Name,
Setup: func() error {
return nil
},
Teardown: func() error {
return nil
},
Template: tt.Path,
Type: "http",
Check: func(buildCommand *exec.Cmd, logfile string) error {
if buildCommand.ProcessState != nil {
if buildCommand.ProcessState.ExitCode() != 0 && !tt.Error {
return fmt.Errorf("Bad exit code. Logfile: %s", logfile)
}
if tt.Error && buildCommand.ProcessState.ExitCode() == 0 {
return fmt.Errorf("Expected Bad exit code.")
}
}

if tt.Outputs != nil {
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)

for key, val := range tt.Outputs {
if matched, _ := regexp.MatchString(val+".*", logsString); !matched {
t.Fatalf(
"logs doesn't contain expected log %v with value %v in %q",
key,
val,
logsString)
}
}

}

return nil
},
}
acctest.TestPlugin(t, testCase)
})
}

}
24 changes: 24 additions & 0 deletions datasource/http/test-fixtures/404_url.pkr.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

source "null" "example" {
communicator = "none"
}

data "http" "basic" {
url = "https://www.packer.io/thisWillFail"
}

locals {
url = "${data.http.basic.url}"
}

build {
name = "mybuild"
sources = [
"source.null.example"
]
provisioner "shell-local" {
inline = [
"echo data is ${local.url}",
]
}
}
Loading