From 79c4b662804bae11dc3f8fccde98339e13beb89b Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Tue, 16 Jun 2026 22:10:10 +0200 Subject: [PATCH 1/5] Add HTTP and archive pipeline helpers --- .github/workflows/ci.yaml | 2 +- .github/workflows/comment-sandbox.yaml | 2 +- .github/workflows/release.yaml | 2 +- README.md | 8 +- actions/archive.go | 92 +++++ actions/http.go | 257 +++++++++++++ docs/AI/README.md | 2 +- docs/COMMANDS.md | 113 +++++- docs/CONTRIBUTING.md | 2 +- docs/INSTALL.md | 2 +- docs/REQUIREMENTS.md | 2 +- go.mod | 12 +- go.sum | 16 +- main.go | 2 + services/archive_service.go | 505 +++++++++++++++++++++++++ services/archive_service_test.go | 116 ++++++ services/http_service.go | 355 +++++++++++++++++ services/http_service_test.go | 139 +++++++ 18 files changed, 1609 insertions(+), 20 deletions(-) create mode 100644 actions/archive.go create mode 100644 actions/http.go create mode 100644 services/archive_service.go create mode 100644 services/archive_service_test.go create mode 100644 services/http_service.go create mode 100644 services/http_service_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 019ea84..dae23ec 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.25' - name: Unit tests run: go test ./... diff --git a/.github/workflows/comment-sandbox.yaml b/.github/workflows/comment-sandbox.yaml index 3e067eb..cbbe148 100644 --- a/.github/workflows/comment-sandbox.yaml +++ b/.github/workflows/comment-sandbox.yaml @@ -35,7 +35,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25" - name: Build pipekit run: make build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1431a07..e8722b7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -23,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.25' cache: true - name: Run tests diff --git a/README.md b/README.md index 67eb61d..5cb268e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
Pipekit — CI/CD pipeline toolkit

- Go Version + Go Version OS Support License

@@ -52,6 +52,12 @@ pipekit wait url http://localhost:8080/healthz --timeout 150s pipekit wait grpc localhost:50051 --service my.package.Worker --timeout 60s pipekit wait ws ws://localhost:8080/events --timeout 60s +# Request an API and extract JSON without curl+jq +pipekit http get https://api.example.com/release --expect-status 200 --jq .tag --raw + +# Pack cross-platform release archives +pipekit archive pack dist/app.tar.zst ./bin/app README.md + # Retry a flaky command with exponential backoff pipekit retry run --attempts 5 --delay 5s --backoff -- helm upgrade --install myapp ./chart diff --git a/actions/archive.go b/actions/archive.go new file mode 100644 index 0000000..89be0a2 --- /dev/null +++ b/actions/archive.go @@ -0,0 +1,92 @@ +package actions + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/AxeForging/pipekit/services" + + "github.com/urfave/cli" +) + +// ArchiveCommand returns archive pack/list/unpack helpers. +func ArchiveCommand() cli.Command { + return cli.Command{ + Name: "archive", + Usage: "pack, list, and unpack tar/zip archives", + Subcommands: []cli.Command{ + { + Name: "pack", + Usage: "create an archive from files or directories", + ArgsUsage: "OUTPUT INPUT...", + Flags: []cli.Flag{ + cli.StringFlag{Name: "format, f", Usage: "zip, tar, tar.gz, tar.xz, or tar.zst (default: detect from output)"}, + }, + Action: func(c *cli.Context) error { + args := c.Args() + if len(args) < 2 { + return cli.NewExitError("usage: archive pack OUTPUT INPUT...", 1) + } + if err := services.PackArchive(args[0], args[1:], c.String("format")); err != nil { + return cli.NewExitError(err.Error(), 1) + } + return nil + }, + }, + { + Name: "unpack", + Usage: "extract an archive", + ArgsUsage: "ARCHIVE", + Flags: []cli.Flag{ + cli.StringFlag{Name: "dest, C", Value: ".", Usage: "destination directory"}, + cli.StringFlag{Name: "format, f", Usage: "zip, tar, tar.gz, tar.xz, or tar.zst (default: detect from input)"}, + cli.IntFlag{Name: "strip-components", Usage: "strip leading path components while extracting"}, + }, + Action: func(c *cli.Context) error { + input, err := firstArgOrErr(c, "ARCHIVE") + if err != nil { + return err + } + if err := services.UnpackArchive(input, c.String("dest"), c.String("format"), c.Int("strip-components")); err != nil { + return cli.NewExitError(err.Error(), 1) + } + return nil + }, + }, + { + Name: "list", + Usage: "list archive entries", + ArgsUsage: "ARCHIVE", + Flags: []cli.Flag{ + cli.StringFlag{Name: "format, f", Usage: "zip, tar, tar.gz, tar.xz, or tar.zst (default: detect from input)"}, + cli.BoolFlag{Name: "json", Usage: "output entry metadata as JSON"}, + }, + Action: func(c *cli.Context) error { + input, err := firstArgOrErr(c, "ARCHIVE") + if err != nil { + return err + } + entries, err := services.ListArchive(input, c.String("format")) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + if c.Bool("json") { + b, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name) + } + fmt.Println(strings.Join(names, "\n")) + return nil + }, + }, + }, + } +} diff --git a/actions/http.go b/actions/http.go new file mode 100644 index 0000000..7cb82dd --- /dev/null +++ b/actions/http.go @@ -0,0 +1,257 @@ +package actions + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/AxeForging/pipekit/services" + + "github.com/urfave/cli" +) + +// HTTPCommand returns curl-like HTTP helpers. +func HTTPCommand() cli.Command { + return cli.Command{ + Name: "http", + Usage: "curl-like HTTP requests with status assertions and JSON extraction", + Subcommands: []cli.Command{ + httpMethodCommand(http.MethodGet), + httpMethodCommand(http.MethodPost), + httpMethodCommand(http.MethodPut), + httpMethodCommand(http.MethodPatch), + httpMethodCommand(http.MethodDelete), + httpChainCommand(), + }, + } +} + +func httpMethodCommand(method string) cli.Command { + return cli.Command{ + Name: strings.ToLower(method), + Usage: method + " an HTTP(S) URL", + ArgsUsage: "URL", + Flags: []cli.Flag{ + cli.StringSliceFlag{Name: "header, H", Usage: "request header as 'Name: value' (repeatable)"}, + cli.StringFlag{Name: "data, d", Usage: "raw request body"}, + cli.StringFlag{Name: "data-file", Usage: "read raw request body from file"}, + cli.StringFlag{Name: "json, j", Usage: "JSON request body"}, + cli.StringFlag{Name: "json-file", Usage: "read JSON request body from file"}, + cli.StringSliceFlag{Name: "form, F", Usage: "application/x-www-form-urlencoded field as key=value (repeatable)"}, + cli.StringSliceFlag{Name: "file", Usage: "multipart file field as name=path (repeatable)"}, + cli.StringFlag{Name: "expect-status", Usage: "comma-separated acceptable HTTP status codes"}, + cli.StringFlag{Name: "jq", Usage: "jq-style response JSON path to print"}, + cli.BoolFlag{Name: "raw, r", Usage: "print extracted strings raw"}, + cli.StringFlag{Name: "output, o", Usage: "write response body or extracted value to file"}, + outputKeyFlag(), + cli.StringFlag{Name: "timeout", Value: "30s", Usage: "request timeout"}, + cli.IntFlag{Name: "retry", Value: 1, Usage: "number of attempts"}, + cli.StringFlag{Name: "retry-delay", Value: "1s", Usage: "delay between retry attempts"}, + cli.BoolFlag{Name: "backoff", Usage: "exponential backoff between retries"}, + cli.BoolFlag{Name: "insecure, k", Usage: "skip TLS certificate verification"}, + cli.BoolFlag{Name: "verbose, v", Usage: "print response status and headers to stderr"}, + }, + Action: func(c *cli.Context) error { + urlStr, err := firstArgOrErr(c, "URL") + if err != nil { + return err + } + timeout, err := time.ParseDuration(c.String("timeout")) + if err != nil { + return cli.NewExitError("invalid timeout: "+err.Error(), 1) + } + retryDelay, err := time.ParseDuration(c.String("retry-delay")) + if err != nil { + return cli.NewExitError("invalid retry-delay: "+err.Error(), 1) + } + headers, err := services.ParseHTTPHeaders(c.StringSlice("header")) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + body, contentType, err := services.BuildHTTPBody(c.String("data"), c.String("data-file"), c.String("json"), c.String("json-file"), c.StringSlice("form")) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + if len(c.StringSlice("file")) > 0 { + if body != nil { + return cli.NewExitError("use --file without --data, --data-file, --json, --json-file, or --form", 1) + } + body, contentType, err = services.BuildMultipartBody(c.StringSlice("file")) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + } + if contentType != "" { + if _, exists := headers["Content-Type"]; !exists { + headers["Content-Type"] = contentType + } + } + + expected, err := parseHTTPStatusList(c.String("expect-status")) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + res, err := services.ExecuteHTTPRequest(services.HTTPRequestOptions{ + Method: method, + URL: urlStr, + Headers: headers, + Body: body, + Timeout: timeout, + ExpectedStatus: expected, + RetryAttempts: c.Int("retry"), + RetryDelay: retryDelay, + Backoff: c.Bool("backoff"), + InsecureTLS: c.Bool("insecure"), + }) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + if c.Bool("verbose") { + printHTTPVerbose(res) + } + + out := res.Body + if path := c.String("jq"); path != "" { + val, err := services.ExtractHTTPJSON(res.Body, path) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + out = []byte(formatHTTPResult(val, c.Bool("raw"))) + } + if output := c.String("output"); output != "" { + return os.WriteFile(output, out, 0644) + } + if outputKey := c.String("to-github-output"); outputKey != "" { + return services.WriteToGitHubOutputValue(outputKey, strings.TrimRight(string(out), "\n")) + } + fmt.Print(string(out)) + return nil + }, + } +} + +func httpChainCommand() cli.Command { + return cli.Command{ + Name: "chain", + Usage: "run a sequence of dependent HTTP requests from a JSON/YAML plan", + ArgsUsage: "PLAN_FILE", + Flags: []cli.Flag{ + cli.StringSliceFlag{Name: "header, H", Usage: "request header as 'Name: value' applied to every step"}, + cli.StringFlag{Name: "expect-status", Usage: "default acceptable HTTP status codes for steps"}, + cli.StringFlag{Name: "timeout", Value: "30s", Usage: "default request timeout"}, + cli.IntFlag{Name: "retry", Value: 1, Usage: "number of attempts per step"}, + cli.StringFlag{Name: "retry-delay", Value: "1s", Usage: "delay between retry attempts"}, + cli.BoolFlag{Name: "backoff", Usage: "exponential backoff between retries"}, + cli.BoolFlag{Name: "insecure, k", Usage: "skip TLS certificate verification"}, + cli.BoolFlag{Name: "verbose, v", Usage: "print per-step status to stderr"}, + outputKeyFlag(), + }, + Action: func(c *cli.Context) error { + file, err := firstArgOrErr(c, "PLAN_FILE") + if err != nil { + return err + } + timeout, err := time.ParseDuration(c.String("timeout")) + if err != nil { + return cli.NewExitError("invalid timeout: "+err.Error(), 1) + } + retryDelay, err := time.ParseDuration(c.String("retry-delay")) + if err != nil { + return cli.NewExitError("invalid retry-delay: "+err.Error(), 1) + } + expected, err := parseHTTPStatusList(c.String("expect-status")) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + headers, err := services.ParseHTTPHeaders(c.StringSlice("header")) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + data, err := os.ReadFile(file) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + format := services.DetectFormat(file) + if format == "" { + format = services.FormatJSON + } + doc, err := services.Decode(data, format) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + plan, err := services.DecodeHTTPChainPlan(doc) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + result, err := services.ExecuteHTTPChain(plan, services.HTTPRequestOptions{ + Headers: headers, + Timeout: timeout, + ExpectedStatus: expected, + RetryAttempts: c.Int("retry"), + RetryDelay: retryDelay, + Backoff: c.Bool("backoff"), + InsecureTLS: c.Bool("insecure"), + }) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + if c.Bool("verbose") { + for _, step := range result.Steps { + fmt.Fprintf(os.Stderr, "%s: HTTP %d\n", step.Name, step.StatusCode) + } + } + b, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + if outputKey := c.String("to-github-output"); outputKey != "" { + return services.WriteToGitHubOutputValue(outputKey, string(b)) + } + fmt.Println(string(b)) + return nil + }, + } +} + +func parseHTTPStatusList(value string) ([]int, error) { + if value == "" { + return nil, nil + } + var out []int + for _, part := range strings.Split(value, ",") { + code, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || code < 100 || code > 999 { + return nil, fmt.Errorf("invalid status code: %s", part) + } + out = append(out, code) + } + return out, nil +} + +func formatHTTPResult(v interface{}, raw bool) string { + if raw { + if s, ok := v.(string); ok { + return s + } + } + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + return string(b) +} + +func printHTTPVerbose(res *services.HTTPResult) { + fmt.Fprintf(os.Stderr, "HTTP %d\n", res.StatusCode) + for name, values := range res.Headers { + for _, value := range values { + fmt.Fprintf(os.Stderr, "%s: %s\n", name, value) + } + } +} diff --git a/docs/AI/README.md b/docs/AI/README.md index fb11b24..2dbda8f 100644 --- a/docs/AI/README.md +++ b/docs/AI/README.md @@ -7,7 +7,7 @@ This document provides structured context for AI assistants working with the pip | Item | Value | |------|-------| | **Purpose** | CLI tool replacing bash one-liners in CI/CD pipelines | -| **Language** | Go 1.24+ | +| **Language** | Go 1.25+ | | **CLI Framework** | `urfave/cli` v1 | | **Repository** | https://github.com/AxeForging/pipekit | | **License** | MIT | diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 3053f52..4527d3b 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -18,6 +18,7 @@ Full reference for every pipekit command and flag. For end-to-end pipeline recip - [`cache-key`](#cache-key) — deterministic cache keys - [`checksum`](#checksum) — release checksums - [`artifact`](#artifact) — artifact manifests and assertions +- [`archive`](#archive) — archive pack/list/unpack - [`git`](#git) — CI-friendly git metadata - [`changelog`](#changelog) — release notes from git history - [`config`](#config) — environment configuration @@ -25,6 +26,7 @@ Full reference for every pipekit command and flag. For end-to-end pipeline recip - [`json` / `yaml`](#json) — read · query · mutate · merge · convert · pretty · table - [`render`](#render) — file templating with sprig-like funcs - [`exec`](#exec) — unified retry + mask + tee + timeout runner +- [`http`](#http) — curl-like HTTP requests and chains - [`url`](#url) — URL parsing - [`image`](#image) — container image ref parsing - [`time`](#time) — timestamps, formatting, arithmetic @@ -637,6 +639,41 @@ pipekit artifact manifest "dist/pipekit-*" --pretty --output dist/artifacts.json --- +## archive + +Create, inspect, and extract archives without depending on platform-specific `tar`, `zip`, `xz`, or `zstd` installs. + +
+Examples + +```sh +# Pack release files +pipekit archive pack dist/app.tar.zst ./bin/app README.md + +# Maximum compression release archive +pipekit archive pack dist/source.tar.xz ./src ./go.mod + +# Windows-friendly archive +pipekit archive pack dist/app.zip ./bin/app.exe README.md + +# Inspect and unpack +pipekit archive list dist/app.tar.zst +pipekit archive list dist/app.zip --json +pipekit archive unpack dist/app.tar.zst --dest ./tmp --strip-components 1 +``` + +| Subcommand | Description | +|---|---| +| `archive pack OUTPUT INPUT...` | Create an archive from files/directories | +| `archive list ARCHIVE` | List entries, optionally as JSON | +| `archive unpack ARCHIVE` | Extract entries safely to a destination | + +Supported formats: `zip`, `tar`, `tar.gz`, `tar.xz`, `tar.zst`. Formats are detected from filenames unless `--format` is set. + +
+ +--- + ## git Read git metadata in formats that are easy to pass between CI steps. @@ -1115,6 +1152,80 @@ pipekit exec --attempts 4 --delay 10s --retry-on-stderr "rate limit|429" \ --- +## http + +Run curl-like HTTP(S) requests with retries, status checks, JSON extraction, and chained captures. + +
+Examples + +```sh +# GET and extract JSON without jq +pipekit http get https://api.example.com/releases/latest \ + --expect-status 200 \ + --jq .tag_name \ + --raw \ + --to-github-output tag + +# POST JSON with retries +pipekit http post https://api.example.com/deploys \ + --header "Authorization: Bearer $TOKEN" \ + --json '{"ref":"main"}' \ + --expect-status 201 \ + --retry 3 \ + --backoff + +# Multipart upload +pipekit http post https://uploads.example.com/artifacts \ + --file artifact=dist/app.tar.zst \ + --expect-status 200 + +# Chain dependent requests with captures and interpolation +pipekit http chain flow.yaml --expect-status 200 --verbose +``` + +`flow.yaml`: + +```yaml +steps: + - name: auth + method: POST + url: https://api.example.com/token + json: '{"client":"ci"}' + expectStatus: [200] + capture: + token: .access_token + - name: deploy + method: POST + url: https://api.example.com/deploys/{{token}} + headers: + Authorization: Bearer {{token}} + json: '{"ref":"main"}' + expectStatus: [201] + capture: + deploy_id: .id +``` + +| Flag | Description | +|---|---| +| `--header, -H` | Request header as `Name: value` | +| `--data`, `--data-file` | Raw body inline or from a file | +| `--json`, `--json-file` | JSON body inline or from a file | +| `--form, -F` | URL-encoded form field as `key=value` | +| `--file` | Multipart file field as `name=path` | +| `--expect-status` | Comma-separated acceptable status codes | +| `--jq` | Extract a jq-style path from response JSON | +| `--raw, -r` | Print extracted strings without JSON quotes | +| `--output, -o` | Write response or extracted value to a file | +| `--to-github-output` | Write response or chain JSON to `$GITHUB_OUTPUT` | +| `--retry`, `--retry-delay`, `--backoff` | Retry controls | +| `--insecure, -k` | Skip TLS certificate verification | +| `--verbose, -v` | Print status/headers or chain step statuses to stderr | + +
+ +--- + ## url ```sh @@ -1203,7 +1314,7 @@ Diagnose pipekit's runtime environment — CI platform detection, expected env v pipekit doctor # [platform] # · ci platform github-actions -# · go runtime go1.24.6 linux/amd64 +# · go runtime go1.25.11 linux/amd64 # # [tools] # ✓ git on PATH diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 022a313..ade484e 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -19,7 +19,7 @@ Working on pipekit itself. For an architectural deep-dive aimed at AI assistants | Tool | Version | |---|---| -| Go | 1.24+ | +| Go | 1.25+ | | `make` | any | | `golangci-lint` | for `make lint` | | `git` | for runtime tests of `diff` / `version next` | diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f465ce2..a9f88db 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -83,7 +83,7 @@ Add the directory containing `pipekit.exe` to your `PATH`, or move it to a direc ## From source -Requires Go 1.24 or later. +Requires Go 1.25 or later. ```sh go install github.com/AxeForging/pipekit@latest diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 78ff168..44e6df3 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -48,7 +48,7 @@ Only relevant if you build from source. | Tool | Version | |---|---| -| Go | 1.24 or later | +| Go | 1.25 or later | | `make` | Any (used by the Makefile; not required for `go install` / `go build`) | | `golangci-lint` | For `make lint` only | diff --git a/go.mod b/go.mod index 022bf2b..c4b9fd1 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/AxeForging/pipekit -go 1.24.0 +go 1.25.0 -toolchain go1.24.6 +toolchain go1.25.11 require ( github.com/Masterminds/semver/v3 v3.4.0 @@ -10,8 +10,10 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/itchyny/gojq v0.12.18 + github.com/klauspost/compress v1.18.6 github.com/pelletier/go-toml/v2 v2.3.1 github.com/rs/zerolog v1.34.0 + github.com/ulikunitz/xz v0.5.15 github.com/urfave/cli v1.22.17 google.golang.org/grpc v1.72.2 gopkg.in/yaml.v3 v3.0.1 @@ -23,9 +25,9 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect google.golang.org/protobuf v1.36.6 // indirect ) diff --git a/go.sum b/go.sum index 6709e98..1627dfc 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -51,6 +53,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -65,15 +69,15 @@ go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 h1:h6p3mQqrmT1XkHVTfzLdNz1u7IhINeZkz67/xTbOuWs= google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= diff --git a/main.go b/main.go index 06975b2..9748cb4 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,7 @@ func main() { actions.CacheKeyCommand(), actions.ChecksumCommand(), actions.ArtifactCommand(), + actions.ArchiveCommand(), actions.GitCommand(), actions.ChangelogCommand(), actions.ConfigCommand(), @@ -49,6 +50,7 @@ func main() { actions.YAMLCommand(), actions.RenderCommand(), actions.ExecCommand(), + actions.HTTPCommand(), actions.URLCommand(), actions.ImageCommand(), actions.TimeCommand(), diff --git a/services/archive_service.go b/services/archive_service.go new file mode 100644 index 0000000..9510317 --- /dev/null +++ b/services/archive_service.go @@ -0,0 +1,505 @@ +package services + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/klauspost/compress/zstd" + "github.com/ulikunitz/xz" +) + +// ArchiveEntry describes one file or directory inside an archive. +type ArchiveEntry struct { + Name string `json:"name"` + Size int64 `json:"size"` + Mode string `json:"mode"` +} + +// PackArchive writes an archive containing the given files/directories. +func PackArchive(output string, inputs []string, format string) error { + if output == "" { + return fmt.Errorf("output archive required") + } + if len(inputs) == 0 { + return fmt.Errorf("at least one input required") + } + format = DetectArchiveFormat(output, format) + if format == "" { + return fmt.Errorf("archive format required") + } + switch format { + case "zip": + return packZip(output, inputs) + case "tar", "tar.gz", "tar.xz", "tar.zst": + return packTar(output, inputs, format) + default: + return fmt.Errorf("unsupported archive format %q", format) + } +} + +// UnpackArchive extracts an archive to dest. +func UnpackArchive(input string, dest string, format string, stripComponents int) error { + if dest == "" { + dest = "." + } + if stripComponents < 0 { + return fmt.Errorf("--strip-components cannot be negative") + } + format = DetectArchiveFormat(input, format) + switch format { + case "zip": + return unpackZip(input, dest, stripComponents) + case "tar", "tar.gz", "tar.xz", "tar.zst": + return unpackTar(input, dest, format, stripComponents) + default: + return fmt.Errorf("unsupported archive format %q", format) + } +} + +// ListArchive returns entries in an archive. +func ListArchive(input string, format string) ([]ArchiveEntry, error) { + format = DetectArchiveFormat(input, format) + switch format { + case "zip": + return listZip(input) + case "tar", "tar.gz", "tar.xz", "tar.zst": + return listTar(input, format) + default: + return nil, fmt.Errorf("unsupported archive format %q", format) + } +} + +// DetectArchiveFormat returns a normalized archive format from a flag or filename. +func DetectArchiveFormat(filename string, override string) string { + if override != "" { + return normalizeArchiveFormat(override) + } + name := strings.ToLower(filename) + switch { + case strings.HasSuffix(name, ".tar.gz"), strings.HasSuffix(name, ".tgz"): + return "tar.gz" + case strings.HasSuffix(name, ".tar.xz"), strings.HasSuffix(name, ".txz"): + return "tar.xz" + case strings.HasSuffix(name, ".tar.zst"), strings.HasSuffix(name, ".tzst"): + return "tar.zst" + case strings.HasSuffix(name, ".tar"): + return "tar" + case strings.HasSuffix(name, ".zip"): + return "zip" + default: + return "" + } +} + +func normalizeArchiveFormat(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case "tgz", "gz", "gzip": + return "tar.gz" + case "txz", "xz": + return "tar.xz" + case "tzst", "zst", "zstd": + return "tar.zst" + default: + return strings.ToLower(strings.TrimSpace(format)) + } +} + +func packTar(output string, inputs []string, format string) error { + out, err := os.Create(output) + if err != nil { + return fmt.Errorf("creating %s: %w", output, err) + } + defer out.Close() + + w, closeFn, err := tarWriter(out, format) + if err != nil { + return err + } + + err = walkArchiveInputs(inputs, func(path, name string, info os.FileInfo) error { + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + hdr.Name = filepath.ToSlash(name) + if info.IsDir() && !strings.HasSuffix(hdr.Name, "/") { + hdr.Name += "/" + } + if err := w.WriteHeader(hdr); err != nil { + return err + } + if info.IsDir() { + return nil + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err + }) + closeErr := closeFn() + if err != nil { + return err + } + return closeErr +} + +func tarWriter(out io.Writer, format string) (*tar.Writer, func() error, error) { + switch format { + case "tar": + tw := tar.NewWriter(out) + return tw, tw.Close, nil + case "tar.gz": + gw := gzip.NewWriter(out) + tw := tar.NewWriter(gw) + return tw, func() error { + if err := tw.Close(); err != nil { + return err + } + return gw.Close() + }, nil + case "tar.xz": + xw, err := xz.NewWriter(out) + if err != nil { + return nil, nil, err + } + tw := tar.NewWriter(xw) + return tw, func() error { + if err := tw.Close(); err != nil { + return err + } + return xw.Close() + }, nil + case "tar.zst": + zw, err := zstd.NewWriter(out) + if err != nil { + return nil, nil, err + } + tw := tar.NewWriter(zw) + return tw, func() error { + if err := tw.Close(); err != nil { + return err + } + zw.Close() + return nil + }, nil + default: + return nil, nil, fmt.Errorf("unsupported tar format %q", format) + } +} + +func tarReader(in io.Reader, format string) (*tar.Reader, func(), error) { + switch format { + case "tar": + return tar.NewReader(in), func() {}, nil + case "tar.gz": + gr, err := gzip.NewReader(in) + if err != nil { + return nil, nil, err + } + return tar.NewReader(gr), func() { gr.Close() }, nil + case "tar.xz": + xr, err := xz.NewReader(in) + if err != nil { + return nil, nil, err + } + return tar.NewReader(xr), func() {}, nil + case "tar.zst": + zr, err := zstd.NewReader(in) + if err != nil { + return nil, nil, err + } + return tar.NewReader(zr), func() { zr.Close() }, nil + default: + return nil, nil, fmt.Errorf("unsupported tar format %q", format) + } +} + +func packZip(output string, inputs []string) error { + out, err := os.Create(output) + if err != nil { + return fmt.Errorf("creating %s: %w", output, err) + } + defer out.Close() + + zw := zip.NewWriter(out) + + err = walkArchiveInputs(inputs, func(path, name string, info os.FileInfo) error { + hdr, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + hdr.Name = filepath.ToSlash(name) + if info.IsDir() && !strings.HasSuffix(hdr.Name, "/") { + hdr.Name += "/" + } + if !info.IsDir() { + hdr.Method = zip.Deflate + } + w, err := zw.CreateHeader(hdr) + if err != nil { + return err + } + if info.IsDir() { + return nil + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err + }) + closeErr := zw.Close() + if err != nil { + return err + } + return closeErr +} + +func walkArchiveInputs(inputs []string, fn func(path, name string, info os.FileInfo) error) error { + for _, input := range inputs { + info, err := os.Stat(input) + if err != nil { + return fmt.Errorf("stat %s: %w", input, err) + } + base := filepath.Dir(input) + if !info.IsDir() { + base = filepath.Dir(input) + } + if err := filepath.Walk(input, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + name, err := filepath.Rel(base, path) + if err != nil { + return err + } + name = cleanArchiveName(name) + if name == "" { + return nil + } + return fn(path, name, info) + }); err != nil { + return err + } + } + return nil +} + +func cleanArchiveName(name string) string { + name = filepath.ToSlash(filepath.Clean(name)) + name = strings.TrimPrefix(name, "./") + if name == "." || name == "/" || strings.HasPrefix(name, "../") || name == ".." { + return "" + } + return strings.TrimPrefix(name, "/") +} + +func unpackTar(input string, dest string, format string, stripComponents int) error { + f, err := os.Open(input) + if err != nil { + return fmt.Errorf("opening %s: %w", input, err) + } + defer f.Close() + + tr, closeFn, err := tarReader(f, format) + if err != nil { + return err + } + defer closeFn() + + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + name, ok, err := stripArchiveName(hdr.Name, stripComponents) + if err != nil { + return err + } + if !ok { + continue + } + target, err := safeArchiveTarget(dest, name) + if err != nil { + return err + } + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)) + if err != nil { + return err + } + _, copyErr := io.Copy(out, tr) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + } + } +} + +func unpackZip(input string, dest string, stripComponents int) error { + zr, err := zip.OpenReader(input) + if err != nil { + return fmt.Errorf("opening %s: %w", input, err) + } + defer zr.Close() + + for _, file := range zr.File { + name, ok, err := stripArchiveName(file.Name, stripComponents) + if err != nil { + return err + } + if !ok { + continue + } + target, err := safeArchiveTarget(dest, name) + if err != nil { + return err + } + if file.FileInfo().IsDir() { + if err := os.MkdirAll(target, file.Mode()); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + rc, err := file.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.Mode()) + if err != nil { + rc.Close() + return err + } + _, copyErr := io.Copy(out, rc) + closeErr := out.Close() + rcErr := rc.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if rcErr != nil { + return rcErr + } + } + return nil +} + +func listTar(input string, format string) ([]ArchiveEntry, error) { + f, err := os.Open(input) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", input, err) + } + defer f.Close() + tr, closeFn, err := tarReader(f, format) + if err != nil { + return nil, err + } + defer closeFn() + + var entries []ArchiveEntry + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + entries = append(entries, ArchiveEntry{Name: hdr.Name, Size: hdr.Size, Mode: os.FileMode(hdr.Mode).String()}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) + return entries, nil +} + +func listZip(input string) ([]ArchiveEntry, error) { + zr, err := zip.OpenReader(input) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", input, err) + } + defer zr.Close() + entries := make([]ArchiveEntry, 0, len(zr.File)) + for _, file := range zr.File { + entries = append(entries, ArchiveEntry{Name: file.Name, Size: int64(file.UncompressedSize64), Mode: file.Mode().String()}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) + return entries, nil +} + +func stripArchiveName(name string, components int) (string, bool, error) { + name, err := validateArchiveEntryName(name) + if err != nil { + return "", false, err + } + if name == "" { + return "", false, nil + } + parts := strings.Split(name, "/") + if len(parts) <= components { + return "", false, nil + } + return strings.Join(parts[components:], "/"), true, nil +} + +func validateArchiveEntryName(name string) (string, error) { + name = filepath.ToSlash(strings.TrimSpace(name)) + name = strings.TrimPrefix(name, "./") + if name == "" || name == "." { + return "", nil + } + if strings.HasPrefix(name, "/") { + return "", fmt.Errorf("archive entry %q escapes destination", name) + } + for _, part := range strings.Split(name, "/") { + if part == ".." { + return "", fmt.Errorf("archive entry %q escapes destination", name) + } + } + return filepath.ToSlash(filepath.Clean(name)), nil +} + +func safeArchiveTarget(dest, name string) (string, error) { + cleanDest, err := filepath.Abs(dest) + if err != nil { + return "", err + } + target, err := filepath.Abs(filepath.Join(cleanDest, filepath.FromSlash(name))) + if err != nil { + return "", err + } + if target != cleanDest && !strings.HasPrefix(target, cleanDest+string(os.PathSeparator)) { + return "", fmt.Errorf("archive entry %q escapes destination", name) + } + return target, nil +} diff --git a/services/archive_service_test.go b/services/archive_service_test.go new file mode 100644 index 0000000..0929deb --- /dev/null +++ b/services/archive_service_test.go @@ -0,0 +1,116 @@ +package services + +import ( + "archive/tar" + "archive/zip" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestArchiveRoundTripFormats(t *testing.T) { + formats := map[string]string{ + "zip": "bundle.zip", + "tar": "bundle.tar", + "tar.gz": "bundle.tar.gz", + "tar.xz": "bundle.tar.xz", + "tar.zst": "bundle.tar.zst", + } + for format, name := range formats { + t.Run(format, func(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + if err := os.MkdirAll(filepath.Join(src, "nested"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + + archive := filepath.Join(dir, name) + if err := PackArchive(archive, []string{src}, format); err != nil { + t.Fatalf("pack: %v", err) + } + entries, err := ListArchive(archive, "") + if err != nil { + t.Fatalf("list: %v", err) + } + if !archiveHasEntry(entries, "src/nested/file.txt") { + t.Fatalf("entries missing file: %#v", entries) + } + + dest := filepath.Join(dir, "out") + if err := UnpackArchive(archive, dest, "", 1); err != nil { + t.Fatalf("unpack: %v", err) + } + got, err := os.ReadFile(filepath.Join(dest, "nested", "file.txt")) + if err != nil { + t.Fatalf("read unpacked: %v", err) + } + if string(got) != "hello" { + t.Fatalf("content = %q", got) + } + }) + } +} + +func TestArchiveRejectsZipTraversal(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "bad.zip") + out, err := os.Create(archive) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(out) + w, err := zw.Create("../escape.txt") + if err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte("bad")) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := out.Close(); err != nil { + t.Fatal(err) + } + + err = UnpackArchive(archive, filepath.Join(dir, "out"), "", 0) + if err == nil || !strings.Contains(err.Error(), "escapes destination") { + t.Fatalf("expected traversal error, got %v", err) + } +} + +func TestArchiveRejectsTarTraversal(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "bad.tar") + out, err := os.Create(archive) + if err != nil { + t.Fatal(err) + } + tw := tar.NewWriter(out) + if err := tw.WriteHeader(&tar.Header{Name: "../escape.txt", Mode: 0644, Size: 3}); err != nil { + t.Fatal(err) + } + _, _ = tw.Write([]byte("bad")) + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := out.Close(); err != nil { + t.Fatal(err) + } + + err = UnpackArchive(archive, filepath.Join(dir, "out"), "", 0) + if err == nil || !strings.Contains(err.Error(), "escapes destination") { + t.Fatalf("expected traversal error, got %v", err) + } +} + +func archiveHasEntry(entries []ArchiveEntry, name string) bool { + for _, entry := range entries { + if entry.Name == name { + return true + } + } + return false +} diff --git a/services/http_service.go b/services/http_service.go new file mode 100644 index 0000000..b2b6d24 --- /dev/null +++ b/services/http_service.go @@ -0,0 +1,355 @@ +package services + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// HTTPRequestOptions configures one HTTP request. +type HTTPRequestOptions struct { + Method string + URL string + Headers map[string]string + Body []byte + Timeout time.Duration + ExpectedStatus []int + RetryAttempts int + RetryDelay time.Duration + Backoff bool + InsecureTLS bool +} + +// HTTPResult contains a completed HTTP response. +type HTTPResult struct { + StatusCode int + Headers http.Header + Body []byte +} + +// ExecuteHTTPRequest runs an HTTP request, optionally retrying failed attempts. +func ExecuteHTTPRequest(opts HTTPRequestOptions) (*HTTPResult, error) { + if opts.Method == "" { + opts.Method = http.MethodGet + } + if opts.Timeout <= 0 { + opts.Timeout = 30 * time.Second + } + if opts.RetryAttempts <= 0 { + opts.RetryAttempts = 1 + } + delay := opts.RetryDelay + if delay <= 0 { + delay = time.Second + } + + var lastErr error + for attempt := 1; attempt <= opts.RetryAttempts; attempt++ { + res, err := doHTTPRequest(opts) + if err == nil { + return res, nil + } + lastErr = err + if attempt == opts.RetryAttempts { + break + } + time.Sleep(delay) + if opts.Backoff { + delay *= 2 + } + } + return nil, lastErr +} + +func doHTTPRequest(opts HTTPRequestOptions) (*HTTPResult, error) { + req, err := http.NewRequest(strings.ToUpper(opts.Method), opts.URL, bytes.NewReader(opts.Body)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + for k, v := range opts.Headers { + req.Header.Set(k, v) + } + + client := &http.Client{Timeout: opts.Timeout} + if opts.InsecureTLS { + client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("requesting %s: %w", opts.URL, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + result := &HTTPResult{StatusCode: resp.StatusCode, Headers: resp.Header.Clone(), Body: body} + if len(opts.ExpectedStatus) > 0 && !statusAllowed(resp.StatusCode, opts.ExpectedStatus) { + return result, fmt.Errorf("HTTP %s returned status %d, expected one of %v", opts.URL, resp.StatusCode, opts.ExpectedStatus) + } + return result, nil +} + +// HTTPChainPlan defines a sequence of dependent HTTP requests. +type HTTPChainPlan struct { + Steps []HTTPChainStep `json:"steps"` +} + +// HTTPChainStep defines one request in an HTTP chain. +type HTTPChainStep struct { + Name string `json:"name"` + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Data string `json:"data"` + JSON string `json:"json"` + ExpectStatus []int `json:"expectStatus"` + Capture map[string]string `json:"capture"` + TimeoutSeconds int `json:"timeoutSeconds"` +} + +// HTTPChainResult is the structured output from a chain run. +type HTTPChainResult struct { + Vars map[string]string `json:"vars"` + Steps []HTTPChainStepResult `json:"steps"` +} + +// HTTPChainStepResult records one step outcome. +type HTTPChainStepResult struct { + Name string `json:"name"` + StatusCode int `json:"statusCode"` +} + +// DecodeHTTPChainPlan converts decoded JSON/YAML/TOML data into a chain plan. +func DecodeHTTPChainPlan(v interface{}) (HTTPChainPlan, error) { + b, err := json.Marshal(v) + if err != nil { + return HTTPChainPlan{}, err + } + var plan HTTPChainPlan + if err := json.Unmarshal(b, &plan); err != nil { + return HTTPChainPlan{}, fmt.Errorf("decoding HTTP chain plan: %w", err) + } + if len(plan.Steps) == 0 { + return HTTPChainPlan{}, fmt.Errorf("HTTP chain requires at least one step") + } + return plan, nil +} + +// ExecuteHTTPChain runs a dependent sequence of HTTP requests. +func ExecuteHTTPChain(plan HTTPChainPlan, base HTTPRequestOptions) (*HTTPChainResult, error) { + vars := map[string]string{} + result := &HTTPChainResult{Vars: vars} + + for i, step := range plan.Steps { + name := step.Name + if name == "" { + name = fmt.Sprintf("step%d", i+1) + } + method := step.Method + if method == "" { + method = http.MethodGet + } + headers := make(map[string]string, len(base.Headers)+len(step.Headers)) + for k, v := range base.Headers { + headers[k] = interpolateHTTPVars(v, vars) + } + for k, v := range step.Headers { + headers[k] = interpolateHTTPVars(v, vars) + } + body, contentType, err := chainStepBody(step, vars) + if err != nil { + return result, fmt.Errorf("%s: %w", name, err) + } + if contentType != "" { + if _, exists := headers["Content-Type"]; !exists { + headers["Content-Type"] = contentType + } + } + timeout := base.Timeout + if step.TimeoutSeconds > 0 { + timeout = time.Duration(step.TimeoutSeconds) * time.Second + } + expected := step.ExpectStatus + if len(expected) == 0 { + expected = base.ExpectedStatus + } + + res, err := ExecuteHTTPRequest(HTTPRequestOptions{ + Method: method, + URL: interpolateHTTPVars(step.URL, vars), + Headers: headers, + Body: body, + Timeout: timeout, + ExpectedStatus: expected, + RetryAttempts: base.RetryAttempts, + RetryDelay: base.RetryDelay, + Backoff: base.Backoff, + InsecureTLS: base.InsecureTLS, + }) + if err != nil { + return result, fmt.Errorf("%s: %w", name, err) + } + result.Steps = append(result.Steps, HTTPChainStepResult{Name: name, StatusCode: res.StatusCode}) + for key, path := range step.Capture { + val, err := ExtractHTTPJSON(res.Body, path) + if err != nil { + return result, fmt.Errorf("%s capture %s: %w", name, key, err) + } + vars[key] = fmt.Sprint(val) + } + } + return result, nil +} + +func chainStepBody(step HTTPChainStep, vars map[string]string) ([]byte, string, error) { + if step.Data != "" && step.JSON != "" { + return nil, "", fmt.Errorf("use only one of data or json") + } + if step.Data != "" { + return []byte(interpolateHTTPVars(step.Data, vars)), "", nil + } + if step.JSON != "" { + body := []byte(interpolateHTTPVars(step.JSON, vars)) + if !json.Valid(body) { + return nil, "", fmt.Errorf("json body is not valid JSON after interpolation") + } + return body, "application/json", nil + } + return nil, "", nil +} + +func interpolateHTTPVars(value string, vars map[string]string) string { + out := value + for k, v := range vars { + out = strings.ReplaceAll(out, "{{"+k+"}}", v) + } + return out +} + +func statusAllowed(got int, expected []int) bool { + for _, code := range expected { + if got == code { + return true + } + } + return false +} + +// ExtractHTTPJSON parses response JSON and returns the value at a jq-style path. +func ExtractHTTPJSON(body []byte, path string) (interface{}, error) { + var doc interface{} + if err := json.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("decoding response JSON: %w", err) + } + return JSONGet(doc, path) +} + +// ParseHTTPHeaders parses repeated "Name: value" header flags. +func ParseHTTPHeaders(headers []string) (map[string]string, error) { + out := make(map[string]string, len(headers)) + for _, h := range headers { + name, value, ok := strings.Cut(h, ":") + if !ok || strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("invalid header %q, expected 'Name: value'", h) + } + out[strings.TrimSpace(name)] = strings.TrimSpace(value) + } + return out, nil +} + +// BuildHTTPBody builds a request body from data, JSON, file, or form inputs. +func BuildHTTPBody(data string, dataFile string, jsonValue string, jsonFile string, formFields []string) ([]byte, string, error) { + set := 0 + for _, v := range []string{data, dataFile, jsonValue, jsonFile} { + if v != "" { + set++ + } + } + if len(formFields) > 0 { + set++ + } + if set > 1 { + return nil, "", fmt.Errorf("use only one of --data, --data-file, --json, --json-file, or --form") + } + + switch { + case data != "": + return []byte(data), "", nil + case dataFile != "": + b, err := os.ReadFile(dataFile) + if err != nil { + return nil, "", fmt.Errorf("reading %s: %w", dataFile, err) + } + return b, "", nil + case jsonValue != "": + if !json.Valid([]byte(jsonValue)) { + return nil, "", fmt.Errorf("--json is not valid JSON") + } + return []byte(jsonValue), "application/json", nil + case jsonFile != "": + b, err := os.ReadFile(jsonFile) + if err != nil { + return nil, "", fmt.Errorf("reading %s: %w", jsonFile, err) + } + if !json.Valid(b) { + return nil, "", fmt.Errorf("%s is not valid JSON", jsonFile) + } + return b, "application/json", nil + case len(formFields) > 0: + values := url.Values{} + for _, field := range formFields { + k, v, ok := strings.Cut(field, "=") + if !ok || k == "" { + return nil, "", fmt.Errorf("invalid form field %q, expected key=value", field) + } + values.Add(k, v) + } + return []byte(values.Encode()), "application/x-www-form-urlencoded", nil + default: + return nil, "", nil + } +} + +// BuildMultipartBody builds a multipart/form-data request body from key=path pairs. +func BuildMultipartBody(fileFields []string) ([]byte, string, error) { + if len(fileFields) == 0 { + return nil, "", nil + } + var b bytes.Buffer + w := multipart.NewWriter(&b) + for _, field := range fileFields { + name, path, ok := strings.Cut(field, "=") + if !ok || name == "" || path == "" { + return nil, "", fmt.Errorf("invalid file field %q, expected name=path", field) + } + f, err := os.Open(path) + if err != nil { + return nil, "", fmt.Errorf("opening %s: %w", path, err) + } + part, err := w.CreateFormFile(name, path) + if err != nil { + f.Close() + return nil, "", err + } + if _, err := io.Copy(part, f); err != nil { + f.Close() + return nil, "", err + } + f.Close() + } + if err := w.Close(); err != nil { + return nil, "", err + } + return b.Bytes(), w.FormDataContentType(), nil +} diff --git a/services/http_service_test.go b/services/http_service_test.go new file mode 100644 index 0000000..2154303 --- /dev/null +++ b/services/http_service_test.go @@ -0,0 +1,139 @@ +package services + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestExecuteHTTPRequestExtractJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Test"); got != "yes" { + t.Fatalf("header = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"release":{"tag":"v1.2.3"}}`)) + })) + defer srv.Close() + + res, err := ExecuteHTTPRequest(HTTPRequestOptions{ + Method: http.MethodGet, + URL: srv.URL, + Headers: map[string]string{"X-Test": "yes"}, + Timeout: 2 * time.Second, + ExpectedStatus: []int{200}, + }) + if err != nil { + t.Fatalf("request: %v", err) + } + val, err := ExtractHTTPJSON(res.Body, ".release.tag") + if err != nil { + t.Fatalf("extract: %v", err) + } + if val != "v1.2.3" { + t.Fatalf("tag = %v", val) + } +} + +func TestExecuteHTTPRequestRetriesStatusFailures(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) < 3 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + res, err := ExecuteHTTPRequest(HTTPRequestOptions{ + Method: http.MethodPost, + URL: srv.URL, + Timeout: 2 * time.Second, + ExpectedStatus: []int{201}, + RetryAttempts: 3, + RetryDelay: time.Millisecond, + }) + if err != nil { + t.Fatalf("request: %v", err) + } + if res.StatusCode != http.StatusCreated { + t.Fatalf("status = %d", res.StatusCode) + } + if calls.Load() != 3 { + t.Fatalf("calls = %d", calls.Load()) + } +} + +func TestBuildHTTPBodyModes(t *testing.T) { + body, contentType, err := BuildHTTPBody("", "", `{"ok":true}`, "", nil) + if err != nil { + t.Fatalf("json body: %v", err) + } + if contentType != "application/json" || !json.Valid(body) { + t.Fatalf("unexpected json body: %s %s", contentType, body) + } + + body, contentType, err = BuildHTTPBody("", "", "", "", []string{"a=1", "b=two words"}) + if err != nil { + t.Fatalf("form body: %v", err) + } + if contentType != "application/x-www-form-urlencoded" || string(body) != "a=1&b=two+words" { + t.Fatalf("unexpected form body: %s %s", contentType, body) + } +} + +func TestExecuteHTTPChainCapturesAndInterpolates(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + _, _ = w.Write([]byte(`{"token":"abc123"}`)) + case "/items/abc123": + if got := r.Header.Get("Authorization"); got != "Bearer abc123" { + t.Fatalf("Authorization = %q", got) + } + _, _ = w.Write([]byte(`{"id":42}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + result, err := ExecuteHTTPChain(HTTPChainPlan{Steps: []HTTPChainStep{ + { + Name: "auth", + Method: "GET", + URL: srv.URL + "/token", + ExpectStatus: []int{200}, + Capture: map[string]string{"token": ".token"}, + }, + { + Name: "item", + Method: "GET", + URL: srv.URL + "/items/{{token}}", + Headers: map[string]string{"Authorization": "Bearer {{token}}"}, + ExpectStatus: []int{200}, + Capture: map[string]string{"item_id": ".id"}, + }, + }}, HTTPRequestOptions{Timeout: 2 * time.Second}) + if err != nil { + t.Fatalf("chain: %v", err) + } + if result.Vars["token"] != "abc123" || result.Vars["item_id"] != "42" { + t.Fatalf("vars = %#v", result.Vars) + } + if len(result.Steps) != 2 { + t.Fatalf("steps = %d", len(result.Steps)) + } +} + +func TestParseHTTPHeadersRejectsInvalid(t *testing.T) { + _, err := ParseHTTPHeaders([]string{"not a header"}) + if err == nil || !strings.Contains(err.Error(), "invalid header") { + t.Fatalf("expected invalid header error, got %v", err) + } +} From be7176a6e7aefcdcec655080d32c7cfa03d7e32b Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Tue, 16 Jun 2026 22:11:52 +0200 Subject: [PATCH 2/5] Clarify HTTP chain documentation --- docs/COMMANDS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 4527d3b..a89d961 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1184,6 +1184,8 @@ pipekit http post https://uploads.example.com/artifacts \ pipekit http chain flow.yaml --expect-status 200 --verbose ``` +`http chain` runs each step in order. Values captured from a response body with jq-style paths are stored as variables, then reused in later `url`, `headers`, `data`, and `json` fields with `{{name}}` interpolation. The command prints JSON containing final `vars` and per-step status codes. + `flow.yaml`: ```yaml From 24de39d80859ef1f69371c2112a44a11a55c0b6d Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Tue, 16 Jun 2026 22:21:48 +0200 Subject: [PATCH 3/5] Add HTTP archive end-to-end coverage --- README.md | 2 + docs/EXAMPLES.md | 63 +++++++++++++++++ integration/integration_test.go | 120 ++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+) diff --git a/README.md b/README.md index 5cb268e..498b437 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ More end-to-end recipes → **[docs/EXAMPLES.md](docs/EXAMPLES.md)** | `cache-key` | Deterministic SHA256 cache keys from files / globs / composite parts | [↗](docs/COMMANDS.md#cache-key) | | `checksum` | Generate / verify release checksums for artifact files | [↗](docs/COMMANDS.md#checksum) | | `artifact` | Assert artifacts exist and generate size/SHA256 manifests | [↗](docs/COMMANDS.md#artifact) | +| `archive` | Pack, list, and unpack zip/tar/tar.gz/tar.xz/tar.zst archives | [↗](docs/COMMANDS.md#archive) | | `git` | CI-friendly git metadata: ref, SHA, tags, dirty state | [↗](docs/COMMANDS.md#git) | | `changelog` | Generate release notes from git commit ranges | [↗](docs/COMMANDS.md#changelog) | | `config` | Resolve env-specific config maps; map branches to environments | [↗](docs/COMMANDS.md#config) | @@ -95,6 +96,7 @@ More end-to-end recipes → **[docs/EXAMPLES.md](docs/EXAMPLES.md)** | `json` / `yaml` | Get / set / del / deep-merge / convert / pretty / table on JSON, YAML, TOML, CSV | [↗](docs/COMMANDS.md#json) | | `render` | Render Go templates with a sprig-like FuncMap and stacked `--values` files | [↗](docs/COMMANDS.md#render) | | `exec` | Unified retry + mask + tee + timeout command runner | [↗](docs/COMMANDS.md#exec) | +| `http` | Curl-like HTTP requests, JSON extraction, uploads, and chained flows | [↗](docs/COMMANDS.md#http) | | `url parse` | Split a URL into `SCHEME / HOST / PORT / USER / PASSWORD / PATH / QUERY` env vars | [↗](docs/COMMANDS.md#url) | | `image parse` | Split a container image ref into registry / repository / tag / digest | [↗](docs/COMMANDS.md#image) | | `time` | RFC3339 / unix / tag / compact / iso timestamps; format conversion; arithmetic | [↗](docs/COMMANDS.md#time) | diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 7747c87..2c1f4e9 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -56,6 +56,67 @@ pipekit wait url http://localhost:8080/healthz --timeout 150s +
+HTTP API → JSON output without curl + jq + +```sh +# BEFORE +tag=$(curl -fsSL https://api.example.com/releases/latest | jq -r '.tag_name') +echo "tag=$tag" >> "$GITHUB_OUTPUT" + +# AFTER +pipekit http get https://api.example.com/releases/latest \ + --expect-status 200 \ + --jq .tag_name \ + --raw \ + --to-github-output tag +``` + +
+ +
+Dependent API calls without temp files + +```yaml +# flow.yaml +steps: + - name: auth + method: POST + url: https://api.example.com/token + json: '{"client":"ci"}' + capture: + token: .access_token + - name: deploy + method: POST + url: https://api.example.com/deploys/{{token}} + headers: + Authorization: Bearer {{token}} + json: '{"ref":"main"}' + expectStatus: [201] + capture: + deploy_id: .id +``` + +```sh +pipekit http chain flow.yaml --expect-status 200 --verbose +``` + +
+ +
+Cross-platform release archive + +```sh +# BEFORE +tar --zstd -cf dist/app.tar.zst bin/app README.md + +# AFTER +pipekit archive pack dist/app.tar.zst bin/app README.md +pipekit archive list dist/app.tar.zst +``` + +
+
Retry a flaky command @@ -541,6 +602,8 @@ pipekit diff dirs --base origin/main \ make build-all pipekit artifact assert "dist/pipekit-*" +pipekit archive pack dist/release.tar.zst dist/pipekit-* README.md +pipekit archive list dist/release.tar.zst pipekit checksum files dist/pipekit-* --output dist/checksums.txt pipekit checksum verify dist/checksums.txt pipekit artifact manifest "dist/pipekit-*" "dist/checksums.txt" \ diff --git a/integration/integration_test.go b/integration/integration_test.go index 4028fc4..3937a81 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -8,6 +8,8 @@ package integration import ( "bytes" "fmt" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" @@ -484,6 +486,124 @@ func TestE2E_ChecksumAndArtifact(t *testing.T) { expectAll(t, stdout, `"path"`, `"size"`, `"sha256"`) } +func TestE2E_HTTPGetAndChain(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/release": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tag":"v1.2.3"}`)) + case "/token": + if r.Method != http.MethodPost { + t.Fatalf("token method = %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"abc123"}`)) + case "/deploys/abc123": + if got := r.Header.Get("Authorization"); got != "Bearer abc123" { + t.Fatalf("Authorization = %q", got) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":42}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + stdout, stderr, code := runPipekit(t, + []string{"http", "get", srv.URL + "/release", "--expect-status", "200", "--jq", ".tag", "--raw"}, "") + if code != 0 { + t.Fatalf("http get exit %d stderr=%s", code, stderr) + } + if strings.TrimSpace(stdout) != "v1.2.3" { + t.Fatalf("http get stdout = %q", stdout) + } + + dir := t.TempDir() + plan := filepath.Join(dir, "flow.yaml") + body := fmt.Sprintf(`steps: + - name: auth + method: POST + url: %s/token + json: '{"client":"ci"}' + expectStatus: [200] + capture: + token: .access_token + - name: deploy + method: POST + url: %s/deploys/{{token}} + headers: + Authorization: Bearer {{token}} + json: '{"ref":"main"}' + expectStatus: [201] + capture: + deploy_id: .id +`, srv.URL, srv.URL) + if err := os.WriteFile(plan, []byte(body), 0644); err != nil { + t.Fatal(err) + } + + stdout, stderr, code = runPipekit(t, + []string{"http", "chain", plan, "--expect-status", "200", "--verbose"}, "") + if code != 0 { + t.Fatalf("http chain exit %d stderr=%s stdout=%s", code, stderr, stdout) + } + expectAll(t, stdout, `"token": "abc123"`, `"deploy_id": "42"`, `"statusCode": 201`) + expectAll(t, stderr, "auth: HTTP 200", "deploy: HTTP 201") +} + +func TestE2E_HTTPRejectsInvalidExpectedStatus(t *testing.T) { + _, stderr, code := runPipekit(t, + []string{"http", "get", "http://127.0.0.1", "--expect-status", "nope"}, "") + if code == 0 { + t.Fatal("expected invalid status to fail") + } + if !strings.Contains(stderr, "invalid status code") { + t.Fatalf("stderr = %q", stderr) + } +} + +func TestE2E_ArchivePackListUnpack(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + if err := os.MkdirAll(filepath.Join(src, "nested"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + + archive := filepath.Join(dir, "bundle.tar.zst") + _, stderr, code := runPipekit(t, + []string{"archive", "pack", archive, src}, "") + if code != 0 { + t.Fatalf("archive pack exit %d stderr=%s", code, stderr) + } + + stdout, stderr, code := runPipekit(t, + []string{"archive", "list", archive}, "") + if code != 0 { + t.Fatalf("archive list exit %d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, "src/nested/file.txt") { + t.Fatalf("archive list stdout = %q", stdout) + } + + dest := filepath.Join(dir, "out") + _, stderr, code = runPipekit(t, + []string{"archive", "unpack", archive, "--dest", dest, "--strip-components", "1"}, "") + if code != 0 { + t.Fatalf("archive unpack exit %d stderr=%s", code, stderr) + } + got, err := os.ReadFile(filepath.Join(dest, "nested", "file.txt")) + if err != nil { + t.Fatalf("read unpacked file: %v", err) + } + if string(got) != "hello" { + t.Fatalf("unpacked content = %q", got) + } +} + func TestE2E_GitAndChangelog(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not on PATH") From dea7147e660b49ef86661199ba1f5a17d4df709f Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Tue, 16 Jun 2026 22:25:47 +0200 Subject: [PATCH 4/5] Support HTTP chain plans from stdin --- actions/http.go | 36 +++++++++++++++++++++++++++------ docs/COMMANDS.md | 20 +++++++++++++++++- docs/EXAMPLES.md | 21 +++++++++++++++++++ integration/integration_test.go | 7 +++++++ 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/actions/http.go b/actions/http.go index 7cb82dd..7f75b7c 100644 --- a/actions/http.go +++ b/actions/http.go @@ -3,6 +3,7 @@ package actions import ( "encoding/json" "fmt" + "io" "net/http" "os" "strconv" @@ -140,9 +141,10 @@ func httpChainCommand() cli.Command { return cli.Command{ Name: "chain", Usage: "run a sequence of dependent HTTP requests from a JSON/YAML plan", - ArgsUsage: "PLAN_FILE", + ArgsUsage: "PLAN_FILE|-", Flags: []cli.Flag{ cli.StringSliceFlag{Name: "header, H", Usage: "request header as 'Name: value' applied to every step"}, + cli.StringFlag{Name: "from", Usage: "plan format override for stdin or extensionless files: json or yaml"}, cli.StringFlag{Name: "expect-status", Usage: "default acceptable HTTP status codes for steps"}, cli.StringFlag{Name: "timeout", Value: "30s", Usage: "default request timeout"}, cli.IntFlag{Name: "retry", Value: 1, Usage: "number of attempts per step"}, @@ -173,14 +175,10 @@ func httpChainCommand() cli.Command { if err != nil { return cli.NewExitError(err.Error(), 1) } - data, err := os.ReadFile(file) + data, format, err := readHTTPChainPlanInput(c, file) if err != nil { return cli.NewExitError(err.Error(), 1) } - format := services.DetectFormat(file) - if format == "" { - format = services.FormatJSON - } doc, err := services.Decode(data, format) if err != nil { return cli.NewExitError(err.Error(), 1) @@ -219,6 +217,32 @@ func httpChainCommand() cli.Command { } } +func readHTTPChainPlanInput(c *cli.Context, file string) ([]byte, services.DataFormat, error) { + format := services.FormatYAML + if from := c.String("from"); from != "" { + format = services.FormatString(from) + } else if file != "-" { + if detected := services.DetectFormat(file); detected != "" { + format = detected + } + } + if file == "-" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, "", fmt.Errorf("reading stdin: %w", err) + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil, "", fmt.Errorf("empty HTTP chain plan on stdin") + } + return data, format, nil + } + data, err := os.ReadFile(file) + if err != nil { + return nil, "", err + } + return data, format, nil +} + func parseHTTPStatusList(value string) ([]int, error) { if value == "" { return nil, nil diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index a89d961..93ab9ad 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1182,9 +1182,27 @@ pipekit http post https://uploads.example.com/artifacts \ # Chain dependent requests with captures and interpolation pipekit http chain flow.yaml --expect-status 200 --verbose + +# The same plan can be passed inline with a heredoc +pipekit http chain - --expect-status 200 <<'YAML' +steps: + - name: auth + method: POST + url: https://api.example.com/token + json: '{"client":"ci"}' + capture: + token: .access_token + - name: deploy + method: POST + url: https://api.example.com/deploys/{{token}} + headers: + Authorization: Bearer {{token}} + json: '{"ref":"main"}' + expectStatus: [201] +YAML ``` -`http chain` runs each step in order. Values captured from a response body with jq-style paths are stored as variables, then reused in later `url`, `headers`, `data`, and `json` fields with `{{name}}` interpolation. The command prints JSON containing final `vars` and per-step status codes. +`http chain` runs each step in order. Pass a plan file, or use `-` to read a JSON/YAML plan from stdin. Values captured from a response body with jq-style paths are stored as variables, then reused in later `url`, `headers`, `data`, and `json` fields with `{{name}}` interpolation. The command prints JSON containing final `vars` and per-step status codes. `flow.yaml`: diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 2c1f4e9..18cd232 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -101,6 +101,27 @@ steps: pipekit http chain flow.yaml --expect-status 200 --verbose ``` +For short one-off chains, pass the plan inline: + +```sh +pipekit http chain - --expect-status 200 <<'YAML' +steps: + - name: auth + method: POST + url: https://api.example.com/token + json: '{"client":"ci"}' + capture: + token: .access_token + - name: deploy + method: POST + url: https://api.example.com/deploys/{{token}} + headers: + Authorization: Bearer {{token}} + json: '{"ref":"main"}' + expectStatus: [201] +YAML +``` +
diff --git a/integration/integration_test.go b/integration/integration_test.go index 3937a81..cbf083e 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -550,6 +550,13 @@ func TestE2E_HTTPGetAndChain(t *testing.T) { } expectAll(t, stdout, `"token": "abc123"`, `"deploy_id": "42"`, `"statusCode": 201`) expectAll(t, stderr, "auth: HTTP 200", "deploy: HTTP 201") + + stdout, stderr, code = runPipekit(t, + []string{"http", "chain", "-", "--expect-status", "200"}, body) + if code != 0 { + t.Fatalf("http chain stdin exit %d stderr=%s stdout=%s", code, stderr, stdout) + } + expectAll(t, stdout, `"token": "abc123"`, `"deploy_id": "42"`, `"statusCode": 201`) } func TestE2E_HTTPRejectsInvalidExpectedStatus(t *testing.T) { From 919d484470132246af682df8729959af621caad0 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Tue, 16 Jun 2026 22:26:42 +0200 Subject: [PATCH 5/5] Cover HTTP chain stdin edge cases --- integration/integration_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/integration/integration_test.go b/integration/integration_test.go index cbf083e..69accc5 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -543,6 +543,7 @@ func TestE2E_HTTPGetAndChain(t *testing.T) { t.Fatal(err) } + // Plain file-backed chain plan. stdout, stderr, code = runPipekit(t, []string{"http", "chain", plan, "--expect-status", "200", "--verbose"}, "") if code != 0 { @@ -551,6 +552,7 @@ func TestE2E_HTTPGetAndChain(t *testing.T) { expectAll(t, stdout, `"token": "abc123"`, `"deploy_id": "42"`, `"statusCode": 201`) expectAll(t, stderr, "auth: HTTP 200", "deploy: HTTP 201") + // Heredoc/stdin-style chain plan (`pipekit http chain - <<'YAML'`). stdout, stderr, code = runPipekit(t, []string{"http", "chain", "-", "--expect-status", "200"}, body) if code != 0 { @@ -559,6 +561,17 @@ func TestE2E_HTTPGetAndChain(t *testing.T) { expectAll(t, stdout, `"token": "abc123"`, `"deploy_id": "42"`, `"statusCode": 201`) } +func TestE2E_HTTPChainRejectsEmptyStdinPlan(t *testing.T) { + _, stderr, code := runPipekit(t, + []string{"http", "chain", "-"}, " \n") + if code == 0 { + t.Fatal("expected empty stdin plan to fail") + } + if !strings.Contains(stderr, "empty HTTP chain plan on stdin") { + t.Fatalf("stderr = %q", stderr) + } +} + func TestE2E_HTTPRejectsInvalidExpectedStatus(t *testing.T) { _, stderr, code := runPipekit(t, []string{"http", "get", "http://127.0.0.1", "--expect-status", "nope"}, "")