From 00d8e27befdfaf8fbf2f61e63152598f4d08f51d Mon Sep 17 00:00:00 2001 From: Hari Om Date: Fri, 15 May 2026 11:31:38 +0530 Subject: [PATCH] feat: add local release server script and update download logic for Packer binaries --- command/sbom_generate.go | 5 +- provisioner/hcp-sbom/provisioner.go | 27 +++- provisioner/hcp-sbom/provisioner_test.go | 64 ++++++++++ scripts/local-release-server.py | 154 +++++++++++++++++++++++ 4 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 scripts/local-release-server.py diff --git a/command/sbom_generate.go b/command/sbom_generate.go index 6aee66855..fac336566 100644 --- a/command/sbom_generate.go +++ b/command/sbom_generate.go @@ -108,7 +108,6 @@ func (cmd *SBOMGenerateCommand) ParseArgs(args []string) (*sbom.Config, int) { } if strings.HasPrefix(arg, "-") { - cmd.Ui.Say(fmt.Sprintf("Warning: unsupported sbom-generate argument ignored: %s", arg)) continue } @@ -154,8 +153,8 @@ Usage: packer sbom-generate [options] This command is typically invoked internally by the hcp-sbom provisioner. Options: -o Output format: cyclonedx-json, spdx-json (default: cyclonedx-json) - --exclude Optional: exclude path glob from scanning (repeatable) - --scope Optional: scan scope: squashed, all-layers (default: squashed) + --exclude Optional: exclude path glob from scanning (repeatable) + --scope Optional: scan scope: squashed, all-layers (default: squashed) Arguments: Path to scan (default: /) Examples: diff --git a/provisioner/hcp-sbom/provisioner.go b/provisioner/hcp-sbom/provisioner.go index 5ce0cf403..551d42e6d 100644 --- a/provisioner/hcp-sbom/provisioner.go +++ b/provisioner/hcp-sbom/provisioner.go @@ -539,13 +539,28 @@ func fileSHA256(path string) (string, error) { return hex.EncodeToString(h.Sum(nil)), nil } +// releaseBaseURL is the base URL for downloading Packer release artifacts. +// Override this to point at a local release server (e.g. for air-gapped testing): +// +// PACKER_RELEASE_SERVER=http://127.0.0.1:3231 +const defaultReleaseBaseURL = "https://releases.hashicorp.com" + +func getReleaseBaseURL() string { + if v := os.Getenv("PACKER_RELEASE_SERVER"); v != "" { + return strings.TrimRight(v, "/") + } + return defaultReleaseBaseURL +} + // downloadPackerRelease downloads the Packer release zip for the given -// GOOS/GOARCH from releases.hashicorp.com, verifies its checksum, and -// extracts the packer binary to a local temp file. +// GOOS/GOARCH, verifies its checksum, and extracts the packer binary to a +// local temp file. Set PACKER_RELEASE_SERVER=http://127.0.0.1:3231 to use +// the local release server instead of releases.hashicorp.com. func downloadPackerRelease(ctx context.Context, goos, goarch, version string) (string, error) { + base := getReleaseBaseURL() fileName := fmt.Sprintf("packer_%s_%s_%s.zip", version, goos, goarch) - url := fmt.Sprintf("https://releases.hashicorp.com/packer/%s/%s", version, fileName) - shaSumsURL := fmt.Sprintf("https://releases.hashicorp.com/packer/%s/packer_%s_SHA256SUMS", version, version) + url := fmt.Sprintf("%s/packer/%s/%s", base, version, fileName) + shaSumsURL := fmt.Sprintf("%s/packer/%s/packer_%s_SHA256SUMS", base, version, version) log.Printf("[INFO] Downloading Packer %s for %s/%s from %s...", version, goos, goarch, url) @@ -708,7 +723,7 @@ func (p *Provisioner) provisionWithNativeGeneration( targetGOARCH = mapped } version := packerversion.Version - ui.Say(fmt.Sprintf("Downloading Packer %s for %s/%s from releases.hashicorp.com...", version, targetGOOS, targetGOARCH)) + ui.Say(fmt.Sprintf("Downloading Packer %s for %s/%s from %s...", version, targetGOOS, targetGOARCH, getReleaseBaseURL())) scannerLocalPath, err := downloadPackerRelease(ctx, targetGOOS, targetGOARCH, version) if err != nil { return fmt.Errorf("failed to download Packer release for %s/%s: %w", targetGOOS, targetGOARCH, err) @@ -779,7 +794,7 @@ func (p *Provisioner) runScanner(ctx context.Context, ui packersdk.Ui, // Use Windows-specific default if on Windows and user hasn't customized executeCommand := p.config.ExecuteCommand if isWindows && executeCommand == "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}" { - // User didn't customize, use Windows default (no sudo, uses sbom-generate subcommand) + // User didn't customize, use Windows default (no sudo) executeCommand = "{{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}" } diff --git a/provisioner/hcp-sbom/provisioner_test.go b/provisioner/hcp-sbom/provisioner_test.go index 070b916e5..a62c3e0b2 100644 --- a/provisioner/hcp-sbom/provisioner_test.go +++ b/provisioner/hcp-sbom/provisioner_test.go @@ -109,6 +109,70 @@ func TestConfigPrepare(t *testing.T) { false, "", }, + { + "auto_generate with scanner_args including -q", + map[string]interface{}{ + "auto_generate": true, + "scanner_args": []string{"-o", "cyclonedx-json", "-q"}, + }, + interpolate.Context{}, + &Config{ + AutoGenerate: true, + ScanPath: "/", + ScannerArgs: []string{"-o", "cyclonedx-json", "-q"}, + ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", + }, + false, + "", + }, + { + "auto_generate with scanner_args excluding -q", + map[string]interface{}{ + "auto_generate": true, + "scanner_args": []string{"-o", "cyclonedx-json"}, + }, + interpolate.Context{}, + &Config{ + AutoGenerate: true, + ScanPath: "/", + ScannerArgs: []string{"-o", "cyclonedx-json"}, + ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", + }, + false, + "", + }, + { + "auto_generate with scanner_args using long scope and exclude flags", + map[string]interface{}{ + "auto_generate": true, + "scanner_args": []string{"-o", "cyclonedx-json", "--scope", "all-layers", "--exclude", "/tmp/**"}, + }, + interpolate.Context{}, + &Config{ + AutoGenerate: true, + ScanPath: "/", + ScannerArgs: []string{"-o", "cyclonedx-json", "--scope", "all-layers", "--exclude", "/tmp/**"}, + ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", + }, + false, + "", + }, + { + "auto_generate with scanner_args using equals scope and exclude flags", + map[string]interface{}{ + "auto_generate": true, + "scanner_args": []string{"-o", "cyclonedx-json", "--scope=all-layers", "--exclude=/var/cache/**"}, + }, + interpolate.Context{}, + &Config{ + AutoGenerate: true, + ScanPath: "/", + ScannerArgs: []string{"-o", "cyclonedx-json", "--scope=all-layers", "--exclude=/var/cache/**"}, + ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", + }, + false, + "", + }, { "auto_generate with deprecated scanner_url (should warn but not fail)", map[string]interface{}{ diff --git a/scripts/local-release-server.py b/scripts/local-release-server.py new file mode 100644 index 000000000..01fe3c684 --- /dev/null +++ b/scripts/local-release-server.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +local-release-server.py +======================= +A local HTTP server that mimics the HashiCorp releases.hashicorp.com structure +for Packer binaries. It reads binaries from the bin/ directory, zips them +on demand, computes SHA256 checksums, and serves them so the hcp-sbom +provisioner can download them without hitting the real release server. + +URL layout served: + GET /packer//packer___.zip -> zip of the binary + GET /packer//packer__SHA256SUMS -> checksum file + +Binary resolution order (bin/ directory, relative to repo root): + 1. bin/packer-- (e.g. bin/packer-linux-amd64) + 2. bin/packer--.exe (Windows) + 3. bin/packer (fallback — whatever single binary is present) + +Usage: + python3 scripts/local-release-server.py [--port 3231] [--bin-dir bin/] +""" + +import argparse +import hashlib +import http.server +import io +import os +import re +import sys +import zipfile +from pathlib import Path +from typing import Optional + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def find_binary(bin_dir, goos, goarch): + # type: (Path, str, str) -> Optional[Path] + """Return the path to the best-matching packer binary for the given OS/arch. + + pkg/ layout: pkg/_/packer (or packer.exe on Windows) + """ + subdir = bin_dir / "{}_{}" .format(goos, goarch) + candidates = [ + subdir / "packer.exe", + subdir / "packer", + ] + for path in candidates: + if path.is_file(): + return path + return None + + +def make_zip(binary_path, goos): + # type: (Path, str) -> bytes + """Zip the binary and return the raw zip bytes.""" + binary_name = "packer.exe" if goos == "windows" else "packer" + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.write(str(binary_path), arcname=binary_name) + return buf.getvalue() + + +def sha256_bytes(data): + # type: (bytes) -> str + return hashlib.sha256(data).hexdigest() + + +class ReleaseHandler(http.server.BaseHTTPRequestHandler): + bin_dir = REPO_ROOT / "pkg" + + ZIP_RE = re.compile(r"^/packer/([^/]+)/packer_([^/]+)_([^/]+)_([^/]+)\.zip$") + SUMS_RE = re.compile(r"^/packer/([^/]+)/packer_([^/]+)_SHA256SUMS$") + + def log_message(self, fmt, *args): + print("[server] {} - {}".format(self.address_string(), fmt % args), file=sys.stderr) + + def send_bytes(self, data, content_type="application/octet-stream"): + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + path = self.path.split("?")[0] + + m = self.ZIP_RE.match(path) + if m: + version, _, goos, goarch = m.group(1), m.group(2), m.group(3), m.group(4) + binary = find_binary(self.bin_dir, goos, goarch) + if binary is None: + self._not_found("no binary found for {}/{} in {}".format(goos, goarch, self.bin_dir)) + return + zip_data = make_zip(binary, goos) + print("[server] serving zip for {}/{} v{} from {} ({} KB)".format( + goos, goarch, version, binary, len(zip_data) // 1024), file=sys.stderr) + self.send_bytes(zip_data, "application/zip") + return + + m = self.SUMS_RE.match(path) + if m: + version = m.group(1) + lines = [] + # pkg/_/packer[.exe] + for subdir in sorted(self.bin_dir.iterdir()): + if not subdir.is_dir(): + continue + parts = subdir.name.split("_", 1) + if len(parts) != 2: + continue + goos, goarch = parts + binary = find_binary(self.bin_dir, goos, goarch) + if binary is None: + continue + zip_data = make_zip(binary, goos) + chk = sha256_bytes(zip_data) + fname = "packer_{}_{}_{}.zip".format(version, goos, goarch) + lines.append("{} {}".format(chk, fname)) + body = "\n".join(lines) + "\n" + self.send_bytes(body.encode(), "text/plain") + return + + self._not_found("unrecognised path: {}".format(path)) + + def _not_found(self, reason): + print("[server] 404 {}".format(reason), file=sys.stderr) + self.send_response(404) + self.end_headers() + self.wfile.write("404 not found: {}\n".format(reason).encode()) + + +def main(): + parser = argparse.ArgumentParser(description="Local Packer release server") + parser.add_argument("--port", type=int, default=3231) + parser.add_argument("--bin-dir", default=str(REPO_ROOT / "pkg")) + args = parser.parse_args() + + ReleaseHandler.bin_dir = Path(args.bin_dir).resolve() + if not ReleaseHandler.bin_dir.is_dir(): + print("error: bin-dir {} does not exist".format(ReleaseHandler.bin_dir), file=sys.stderr) + sys.exit(1) + + server = http.server.HTTPServer(("127.0.0.1", args.port), ReleaseHandler) + print("[server] listening on http://127.0.0.1:{}".format(args.port), file=sys.stderr) + print("[server] serving binaries from {}".format(ReleaseHandler.bin_dir), file=sys.stderr) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n[server] stopped", file=sys.stderr) + + +if __name__ == "__main__": + main()