mirror of https://github.com/hashicorp/packer
parent
703e0de15c
commit
e534589220
@ -0,0 +1,239 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
//go:generate packer-sdc mapstructure-to-hcl2 -type Config
|
||||
//go:generate packer-sdc struct-markdown
|
||||
|
||||
package hcp_sbom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/hashicorp/hcl/v2/hcldec"
|
||||
"github.com/hashicorp/packer-plugin-sdk/common"
|
||||
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
|
||||
"github.com/hashicorp/packer-plugin-sdk/template/config"
|
||||
"github.com/hashicorp/packer-plugin-sdk/template/interpolate"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
common.PackerConfig `mapstructure:",squash"`
|
||||
Source string `mapstructure:"source" required:"true"`
|
||||
Destination string `mapstructure:"destination"`
|
||||
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, &config.DecodeOpts{
|
||||
PluginType: "hcp-sbom",
|
||||
Interpolate: true,
|
||||
InterpolateContext: &p.config.ctx,
|
||||
InterpolateFilter: &interpolate.RenderFilter{
|
||||
Exclude: []string{},
|
||||
},
|
||||
}, raws...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs *packersdk.MultiError
|
||||
if p.config.Source == "" {
|
||||
errs = packersdk.MultiErrorAppend(errs, errors.New("source must be specified"))
|
||||
}
|
||||
|
||||
if errs != nil && len(errs.Errors) > 0 {
|
||||
return errs
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provisioner) Provision(
|
||||
ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, generatedData map[string]interface{},
|
||||
) error {
|
||||
ui.Say(
|
||||
fmt.Sprintf("Starting to provision with hcp-sbom using source: %s",
|
||||
p.config.Source,
|
||||
),
|
||||
)
|
||||
|
||||
if generatedData == nil {
|
||||
generatedData = make(map[string]interface{})
|
||||
}
|
||||
p.config.ctx.Data = generatedData
|
||||
|
||||
// Download the file
|
||||
destPath, downloadErr := p.downloadSBOM(ui, comm)
|
||||
// defer os.Remove(destPath)
|
||||
if downloadErr != nil {
|
||||
return fmt.Errorf("failed to download file: %w", downloadErr)
|
||||
}
|
||||
|
||||
// Validate the file
|
||||
ui.Say(fmt.Sprintf("Validating SBOM file %s", destPath))
|
||||
validationErr := p.validateSBOM(ui, destPath)
|
||||
if validationErr != nil {
|
||||
return fmt.Errorf("failed to validate SBOM file: %w", validationErr)
|
||||
}
|
||||
|
||||
// Compress the file
|
||||
ui.Say(fmt.Sprintf("Compressing SBOM file %s", destPath))
|
||||
_, compessionErr := p.compressFile(ui, destPath)
|
||||
if compessionErr != nil {
|
||||
return fmt.Errorf("failed to compress file: %w", compessionErr)
|
||||
}
|
||||
|
||||
// Future: send compressedData to the internal API as per RFC
|
||||
// ...
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadSBOM downloads a Software Bill of Materials (SBOM) from a specified
|
||||
// source to a local destination. It works with all communicators from packersdk.
|
||||
// The method returns the path to the downloaded file or an error if any issues
|
||||
// occur during the download process.
|
||||
func (p *Provisioner) downloadSBOM(ui packersdk.Ui, comm packersdk.Communicator) (string, error) {
|
||||
src, err := interpolate.Render(p.config.Source, &p.config.ctx)
|
||||
if err != nil {
|
||||
return p.config.Destination, fmt.Errorf("error interpolating source: %s", err)
|
||||
}
|
||||
|
||||
// Check if the source is a JSON file
|
||||
if filepath.Ext(src) != ".json" {
|
||||
return p.config.Destination, fmt.Errorf(
|
||||
"packer SBOM source file is not a JSON file: %s", src,
|
||||
)
|
||||
}
|
||||
|
||||
// Determine the destination path
|
||||
dst := p.config.Destination
|
||||
if dst == "" {
|
||||
tmpFile, err := os.CreateTemp("", "packer-sbom-*.json")
|
||||
if err != nil {
|
||||
return dst, fmt.Errorf(
|
||||
"failed to create file for Packer SBOM: %s", err,
|
||||
)
|
||||
}
|
||||
dst = tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
} else {
|
||||
dst, err = interpolate.Render(dst, &p.config.ctx)
|
||||
if err != nil {
|
||||
return dst, fmt.Errorf("error interpolating Packer SBOM destination: %s", err)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(dst, "/") {
|
||||
info, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
return dst, fmt.Errorf("failed to stat destination for Packer SBOM: %s", err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
tmpFile, err := os.CreateTemp(dst, "packer-sbom-*.json")
|
||||
if err != nil {
|
||||
return dst, fmt.Errorf("failed to create file for Packer SBOM: %s", err)
|
||||
}
|
||||
dst = tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the destination directory exists
|
||||
dir := filepath.Dir(dst)
|
||||
if err := os.MkdirAll(dir, os.FileMode(0755)); err != nil {
|
||||
return dst, fmt.Errorf("failed to create destination directory for Packer SBOM: %s", err)
|
||||
}
|
||||
|
||||
// Open the destination file
|
||||
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return dst, fmt.Errorf("failed to open destination file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Create MultiWriter for the current progress
|
||||
pf := io.MultiWriter(f)
|
||||
|
||||
// Download the file
|
||||
ui.Say(fmt.Sprintf("Downloading SBOM file %s => %s", src, dst))
|
||||
if err = comm.Download(src, pf); err != nil {
|
||||
ui.Error(fmt.Sprintf("download failed for SBOM file: %s", err))
|
||||
return dst, err
|
||||
}
|
||||
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func (p *Provisioner) compressFile(ui packersdk.Ui, filePath string) ([]byte, error) {
|
||||
sourceFile, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
data, err := io.ReadAll(sourceFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encoder, err := zstd.NewWriter(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer encoder.Close()
|
||||
|
||||
compressedData := encoder.EncodeAll(data, nil)
|
||||
|
||||
ui.Say(fmt.Sprintf("SBOM file compressed successfully. Size: %d bytes", len(compressedData)))
|
||||
return compressedData, nil
|
||||
}
|
||||
|
||||
type SBOM struct {
|
||||
BomFormat string `json:"bomFormat"`
|
||||
SpecVersion string `json:"specVersion"`
|
||||
}
|
||||
|
||||
func (p *Provisioner) validateSBOM(ui packersdk.Ui, filePath string) error {
|
||||
sourceFile, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
data, err := io.ReadAll(sourceFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sbom SBOM
|
||||
if err := json.Unmarshal(data, &sbom); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal JSON: %w", err)
|
||||
}
|
||||
|
||||
if sbom.BomFormat != "CycloneDX" {
|
||||
return fmt.Errorf("invalid bomFormat: %s", sbom.BomFormat)
|
||||
}
|
||||
|
||||
if sbom.SpecVersion == "" {
|
||||
return fmt.Errorf("specVersion is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
// Code generated by "packer-sdc mapstructure-to-hcl2"; DO NOT EDIT.
|
||||
|
||||
package hcp_sbom
|
||||
|
||||
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 {
|
||||
PackerBuildName *string `mapstructure:"packer_build_name" cty:"packer_build_name" hcl:"packer_build_name"`
|
||||
PackerBuilderType *string `mapstructure:"packer_builder_type" cty:"packer_builder_type" hcl:"packer_builder_type"`
|
||||
PackerCoreVersion *string `mapstructure:"packer_core_version" cty:"packer_core_version" hcl:"packer_core_version"`
|
||||
PackerDebug *bool `mapstructure:"packer_debug" cty:"packer_debug" hcl:"packer_debug"`
|
||||
PackerForce *bool `mapstructure:"packer_force" cty:"packer_force" hcl:"packer_force"`
|
||||
PackerOnError *string `mapstructure:"packer_on_error" cty:"packer_on_error" hcl:"packer_on_error"`
|
||||
PackerUserVars map[string]string `mapstructure:"packer_user_variables" cty:"packer_user_variables" hcl:"packer_user_variables"`
|
||||
PackerSensitiveVars []string `mapstructure:"packer_sensitive_variables" cty:"packer_sensitive_variables" hcl:"packer_sensitive_variables"`
|
||||
Source *string `mapstructure:"source" required:"true" cty:"source" hcl:"source"`
|
||||
Destination *string `mapstructure:"destination" cty:"destination" hcl:"destination"`
|
||||
}
|
||||
|
||||
// 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{
|
||||
"packer_build_name": &hcldec.AttrSpec{Name: "packer_build_name", Type: cty.String, Required: false},
|
||||
"packer_builder_type": &hcldec.AttrSpec{Name: "packer_builder_type", Type: cty.String, Required: false},
|
||||
"packer_core_version": &hcldec.AttrSpec{Name: "packer_core_version", Type: cty.String, Required: false},
|
||||
"packer_debug": &hcldec.AttrSpec{Name: "packer_debug", Type: cty.Bool, Required: false},
|
||||
"packer_force": &hcldec.AttrSpec{Name: "packer_force", Type: cty.Bool, Required: false},
|
||||
"packer_on_error": &hcldec.AttrSpec{Name: "packer_on_error", Type: cty.String, Required: false},
|
||||
"packer_user_variables": &hcldec.AttrSpec{Name: "packer_user_variables", Type: cty.Map(cty.String), Required: false},
|
||||
"packer_sensitive_variables": &hcldec.AttrSpec{Name: "packer_sensitive_variables", Type: cty.List(cty.String), Required: false},
|
||||
"source": &hcldec.AttrSpec{Name: "source", Type: cty.String, Required: false},
|
||||
"destination": &hcldec.AttrSpec{Name: "destination", Type: cty.String, Required: false},
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
package hcp_sbom
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/hashicorp/packer-plugin-sdk/packer"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type MockUi struct {
|
||||
packer.Ui
|
||||
}
|
||||
|
||||
func (m *MockUi) Say(message string) {
|
||||
fmt.Println(message)
|
||||
}
|
||||
|
||||
func (m *MockUi) Error(message string) {
|
||||
fmt.Println("ERROR:", message)
|
||||
}
|
||||
|
||||
type MockCommunicator struct {
|
||||
packer.Communicator
|
||||
}
|
||||
|
||||
func (m *MockCommunicator) Download(src string, dst io.Writer) error {
|
||||
_, err := dst.Write([]byte("mock SBOM content"))
|
||||
return err
|
||||
}
|
||||
|
||||
func TestDownloadSBOM(t *testing.T) {
|
||||
ui := &MockUi{}
|
||||
comm := &MockCommunicator{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Source is a dir, Dest is a dir",
|
||||
config: Config{
|
||||
Source: "mock-source/",
|
||||
Destination: "test-dir/",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Source is a json file, Destination is a dir",
|
||||
config: Config{
|
||||
Source: "mock-source/sbom.json",
|
||||
Destination: "test-dir/",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Source is a json file, Destination is a json file",
|
||||
config: Config{
|
||||
Source: "mock-source/sbom.json",
|
||||
Destination: "sbom.json",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Source is a json file, Destination is a json file in test-output-data",
|
||||
config: Config{
|
||||
Source: "mock-source/sbom.json",
|
||||
Destination: "test-output-data/sbom.json",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Source is a json file, Destination is test-output-data w/o /",
|
||||
config: Config{
|
||||
Source: "mock-source/sbom.json",
|
||||
Destination: "test-output-data",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Source is a json file, Destination is empty",
|
||||
config: Config{
|
||||
Source: "mock-source/sbom.json",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
provisioner := &Provisioner{
|
||||
config: tt.config,
|
||||
}
|
||||
|
||||
destPath, err := provisioner.downloadSBOM(ui, comm)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(destPath); os.IsNotExist(err) {
|
||||
t.Fatalf("expected file to exist at %s", destPath)
|
||||
}
|
||||
|
||||
os.RemoveAll(destPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSBOM(t *testing.T) {
|
||||
provisioner := &Provisioner{}
|
||||
ui := &MockUi{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sbom SBOM
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid SBOM",
|
||||
sbom: SBOM{
|
||||
BomFormat: "CycloneDX",
|
||||
SpecVersion: "1.0",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid BomFormat",
|
||||
sbom: SBOM{
|
||||
BomFormat: "InvalidFormat",
|
||||
SpecVersion: "1.0",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid bomFormat: InvalidFormat",
|
||||
},
|
||||
{
|
||||
name: "Empty SpecVersion",
|
||||
sbom: SBOM{
|
||||
BomFormat: "CycloneDX",
|
||||
SpecVersion: "",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "specVersion is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, _ := json.Marshal(tt.sbom)
|
||||
filePath := "test-sbom.json"
|
||||
os.WriteFile(filePath, data, 0644)
|
||||
defer os.Remove(filePath)
|
||||
|
||||
err := provisioner.validateSBOM(ui, filePath)
|
||||
if tt.expectError {
|
||||
if err == nil || err.Error() != tt.errorMsg {
|
||||
t.Fatalf("expected error %v, got %v", tt.errorMsg, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressFile(t *testing.T) {
|
||||
ui := &MockUi{}
|
||||
provisioner := &Provisioner{}
|
||||
validSBOM := SBOM{
|
||||
BomFormat: "CycloneDX",
|
||||
SpecVersion: "1.0",
|
||||
}
|
||||
data, _ := json.Marshal(validSBOM)
|
||||
filePath := "data.json"
|
||||
//os.WriteFile(filePath, data, 0644)
|
||||
//defer os.Remove(filePath)
|
||||
|
||||
sourceFile, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error:%v", err)
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
data, err = io.ReadAll(sourceFile)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error:%v", err)
|
||||
}
|
||||
|
||||
compressedData, err := provisioner.compressFile(ui, filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
decoder, err := zstd.NewReader(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
defer decoder.Close()
|
||||
|
||||
decompressedData, err := decoder.DecodeAll(compressedData, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if string(decompressedData) != string(data) {
|
||||
t.Fatalf("expected decompressed data to be '%s', got %s", data, decompressedData)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
{"bomFormat":"InvalidFormat","specVersion":"1.0"}
|
||||
@ -0,0 +1,16 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package version
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/packer-plugin-sdk/version"
|
||||
packerVersion "github.com/hashicorp/packer/version"
|
||||
)
|
||||
|
||||
var HCPSBOMPluginVersion *version.PluginVersion
|
||||
|
||||
func init() {
|
||||
HCPSBOMPluginVersion = version.NewPluginVersion(
|
||||
packerVersion.Version, packerVersion.VersionPrerelease, packerVersion.VersionMetadata)
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
<!-- Code generated from the comments of the Config struct in provisioner/hcp_sbom/provisioner.go; DO NOT EDIT MANUALLY -->
|
||||
|
||||
- `destination` (string) - Destination
|
||||
|
||||
<!-- End of code generated from the comments of the Config struct in provisioner/hcp_sbom/provisioner.go; -->
|
||||
@ -0,0 +1,5 @@
|
||||
<!-- Code generated from the comments of the Config struct in provisioner/hcp_sbom/provisioner.go; DO NOT EDIT MANUALLY -->
|
||||
|
||||
- `source` (string) - Source
|
||||
|
||||
<!-- End of code generated from the comments of the Config struct in provisioner/hcp_sbom/provisioner.go; -->
|
||||
Loading…
Reference in new issue