Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ override.tf.json

# CLI configuration files
.terraformrc
terraform.rc
terraform.rc
75 changes: 75 additions & 0 deletions go-meshapi-client/meshapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
)

const (
Expand All @@ -14,6 +16,13 @@ const (
EPRunSourceUpdate = "%s/api/meshobjects/meshbuildingblockruns/%s/status/source/%s"

BlockRunMediaTypeV1 = "application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json"

// maxArtifactBytes caps a streamed artifact download (e.g. a saved terraform plan) so a
// misbehaving or compromised server cannot exhaust the runner's disk.
maxArtifactBytes = 128 << 20 // 128 MiB
// maxErrorBodyBytes caps how much of a non-2xx response body is read into memory for the
// error message, so a huge error response cannot exhaust the runner's RAM.
maxErrorBodyBytes = 16 << 20 // 16 MiB
)

var (
Expand Down Expand Up @@ -114,6 +123,72 @@ func (c *Client) FetchRun(runnerUUID string) (*RunDetailsDTO, []byte, error) {
return dto, data, nil
}

// DownloadArtifact performs an authenticated GET and streams the response body into w rather than
// buffering it, since artifacts like terraform plans can be large. A non-2xx response is returned
// as an error rather than treated as empty, so a missing/expired artifact fails the run visibly.
func (c *Client) DownloadArtifact(artifactURL string, w io.Writer) error {
// Enforce same-origin (scheme + host) as the configured baseURL before attaching auth. The
// artifact URL is an absolute href taken from an API response; if a malicious or buggy API ever
// returned an href pointing at a different host, setHeaders would leak the run bearer token to
// that host (and enable SSRF-style requests through the runner). Reject any cross-origin URL.
if err := c.assertSameOrigin(artifactURL); err != nil {
return err
}

req, err := http.NewRequest(http.MethodGet, artifactURL, nil)
if err != nil {
return fmt.Errorf("download artifact %s: failed to create request: %w", artifactURL, err)
}
// Reuse the standard auth/runner headers, then request raw bytes instead of HAL+JSON.
c.setHeaders(req)
req.Header.Set("Accept", "application/octet-stream")
req.Header.Del("Content-Type") // no request body

resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("download artifact %s: request failed: %w", artifactURL, err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
if readErr != nil {
return fmt.Errorf("download artifact %s returned HTTP %d (failed to read error body: %v)", artifactURL, resp.StatusCode, readErr)
}
return fmt.Errorf("download artifact %s returned HTTP %d: %s", artifactURL, resp.StatusCode, string(respBody))
}

// Bound the streamed copy so an unexpectedly huge response cannot fill the disk. We read one
// byte past the limit so an oversized artifact is rejected rather than silently truncated.
n, err := io.Copy(w, io.LimitReader(resp.Body, maxArtifactBytes+1))
if err != nil {
return fmt.Errorf("download artifact %s: failed to read body: %w", artifactURL, err)
}
if n > maxArtifactBytes {
return fmt.Errorf("download artifact %s: artifact exceeds the maximum allowed size of %d bytes", artifactURL, maxArtifactBytes)
}

return nil
}

// This guards DownloadArtifact against leaking the run bearer
// token — attached by setHeaders — to any host other than the meshfed API the client was created for.
func (c *Client) assertSameOrigin(artifactURL string) error {
base, err := url.Parse(c.baseURL)
if err != nil {
return fmt.Errorf("download artifact %s: invalid client baseURL %q: %w", artifactURL, c.baseURL, err)
}
target, err := url.Parse(artifactURL)
if err != nil {
return fmt.Errorf("download artifact %s: invalid artifact URL: %w", artifactURL, err)
}
if !strings.EqualFold(target.Scheme, base.Scheme) || !strings.EqualFold(target.Host, base.Host) {
return fmt.Errorf("download artifact %s: refusing to send authenticated request to a host other than %s://%s",
artifactURL, base.Scheme, base.Host)
}
return nil
}

// RegisterSource registers the caller as a status source for the given run via POST.
// If the source is already registered (HTTP 409 Conflict) the call is treated as a no-op.
func (c *Client) RegisterSource(runID string, registration RegistrationDTO) error {
Expand Down
77 changes: 77 additions & 0 deletions go-meshapi-client/meshapi/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package meshapi

import (
"bytes"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDownloadArtifact_StreamsBodyIntoWriter(t *testing.T) {
payload := []byte("a saved terraform plan, possibly quite large")

var gotAccept, gotContentType, gotNodeID, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
gotAccept = r.Header.Get("Accept")
gotContentType = r.Header.Get("Content-Type")
gotNodeID = r.Header.Get("X-Block-Runner-Node-Id")
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
w.Write(payload)
}))
defer srv.Close()

client := NewClientWithHTTP(srv.URL, "test-node", BearerTokenAuth{Token: "run-token"}, srv.Client())

var buf bytes.Buffer
err := client.DownloadArtifact(srv.URL+"/artifact", &buf)

require.NoError(t, err)
assert.Equal(t, payload, buf.Bytes(), "body should be streamed verbatim into the writer")
assert.Equal(t, "application/octet-stream", gotAccept, "should request raw bytes, not HAL+JSON")
assert.Empty(t, gotContentType, "Content-Type should be removed for a bodyless GET")
assert.Equal(t, "test-node", gotNodeID, "standard runner headers should still be sent")
assert.Equal(t, "Bearer run-token", gotAuth, "run auth should still be applied")
}

func TestDownloadArtifact_RejectsCrossOriginURL(t *testing.T) {
// The artifact host must match the client's configured baseURL, otherwise the run bearer token
// attached by setHeaders would leak to a foreign host. The request must never be issued.
var reached bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

client := NewClientWithHTTP("https://api.example.com", "test-node", BearerTokenAuth{Token: "run-token"}, srv.Client())

var buf bytes.Buffer
err := client.DownloadArtifact(srv.URL+"/artifact", &buf)

require.Error(t, err)
assert.Contains(t, err.Error(), "refusing to send authenticated request")
assert.False(t, reached, "the cross-origin request must not be issued")
assert.Empty(t, buf.Bytes())
}

func TestDownloadArtifact_Non2xxReturnsErrorWithBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "artifact expired", http.StatusNotFound)
}))
defer srv.Close()

client := NewClientWithHTTP(srv.URL, "test-node", BearerTokenAuth{Token: "run-token"}, srv.Client())

var buf bytes.Buffer
err := client.DownloadArtifact(srv.URL+"/artifact", &buf)

require.Error(t, err)
assert.Contains(t, err.Error(), "404")
assert.Contains(t, err.Error(), "artifact expired", "non-2xx body should surface to the caller")
assert.Empty(t, buf.Bytes(), "nothing should be written on a non-2xx response")
}
4 changes: 4 additions & 0 deletions go-meshapi-client/meshapi/dtos.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ type LinksDTO struct {
RegisterSource LinkDTO `json:"registerSource"`
UpdateSource LinkDTO `json:"updateSource"`
MeshstackBaseUrl LinkDTO `json:"meshstackBaseUrl"`
// PlanArtifact carries a link ONLY when this APPLY run must apply a predecessor DETECT run's
// saved terraform plan. Because it is a value (not pointer) struct, an absent JSON field
// unmarshals to a zero LinkDTO: an empty Href is the runner's signal to perform a plain apply.
PlanArtifact LinkDTO `json:"planArtifact,omitempty"`
}

type LinkDTO struct {
Expand Down
2 changes: 2 additions & 0 deletions tf-block-runner/tfrun/dtos.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func runDTOToInternal(dto *meshapi.RunDetailsDTO) (*Run, error) {
Source: source,
UseMeshBackendFallback: impl.UseMeshHttpBackendFallback,
PreRunScript: impl.PreRunScript,
PlanArtifactUrl: dto.Links.PlanArtifact.Href,
}, nil
}

Expand Down Expand Up @@ -111,6 +112,7 @@ func ToInternalWithoutDecryption(dto *meshapi.RunDetailsDTO) (*Run, error) {
Source: source,
UseMeshBackendFallback: impl.UseMeshHttpBackendFallback,
PreRunScript: impl.PreRunScript,
PlanArtifactUrl: dto.Links.PlanArtifact.Href,
}, nil
}

Expand Down
3 changes: 3 additions & 0 deletions tf-block-runner/tfrun/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ type Run struct {
PreRunScript *string
RunToken string
MeshstackBaseUrl string
// PlanArtifactUrl is set (from the runner-facing _links.planArtifact.href) only when this
// APPLY run must apply a predecessor DETECT run's saved terraform plan. Empty => plain apply.
PlanArtifactUrl string
}

type Variable struct {
Expand Down
8 changes: 8 additions & 0 deletions tf-block-runner/tfrun/runapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tfrun
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"

Expand Down Expand Up @@ -47,6 +48,9 @@ type RunApi interface {
Register(status *RunStatus) error
SetRunToken(token string) // Set the runToken from the fetched run
ClearRunToken() // Clear the runToken to force basic auth for next fetch
// DownloadPredecessorArtifact streams the bytes referenced by the given absolute URL
// (the runner-facing _links.planArtifact.href) into w using the current run authentication.
DownloadPredecessorArtifact(url string, w io.Writer) error
}

func NewRunApi() RunApi {
Expand Down Expand Up @@ -75,6 +79,10 @@ func (api *RunApiClient) ClearRunToken() {
api.auth.runToken = nil
}

func (api *RunApiClient) DownloadPredecessorArtifact(url string, w io.Writer) error {
return api.client.DownloadArtifact(url, w)
}

func (api *RunApiClient) FetchRunDetails(nodePostfix string) (*Run, error) {
requester := fmt.Sprintf("%s-%s", api.rid, nodePostfix)

Expand Down
3 changes: 2 additions & 1 deletion tf-block-runner/tfrun/singlerunworker.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,14 @@ func (w *SingleRunWorker) workRoutine(ctx context.Context, run *Run, wg *sync.Wa
source: run.Source,
preRunScript: run.PreRunScript,
runMode: run.Behavior.str(),
planArtifactUrl: run.PlanArtifactUrl,
}

var tfCommand TfCmd

switch run.Behavior {
case APPLY:
tfCommand = ApplyCmd(ctx, params, w.tfBinaries)
tfCommand = ApplyCmd(ctx, params, w.tfBinaries, w.runApi)
case DETECT:
tfCommand = PlanCmd(ctx, params, w.tfBinaries)
case DESTROY:
Expand Down
85 changes: 79 additions & 6 deletions tf-block-runner/tfrun/tfapply.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,31 @@ package tfrun

import (
"context"
"fmt"
"os"

"github.com/hashicorp/terraform-exec/tfexec"
)

type TfApplyCommand struct {
GenericTfCmd
// runApi is the authenticated run API client (same one used for status updates). APPLY is the
// only command that needs it: it downloads the predecessor plan artifact when
// params.planArtifactUrl is set.
runApi RunApi
}

func ApplyCmd(ctx context.Context, params *TfCmdParams, tfbin *TfBinaries) *TfApplyCommand {
func ApplyCmd(ctx context.Context, params *TfCmdParams, tfbin *TfBinaries, runApi RunApi) *TfApplyCommand {
runContextInfo := ctx.Value(runInfoContextKey).(*RunContextInfo)

return &TfApplyCommand{
GenericTfCmd{
GenericTfCmd: GenericTfCmd{
ctx: ctx,
runContextInfo: runContextInfo,
bin: tfbin,
params: params,
},
runApi: runApi,
}
}

Expand Down Expand Up @@ -157,10 +166,26 @@ func (tfcmd *TfApplyCommand) execute() {

tfcmd.advanceStep(preRunUserMsg)

// Variables are now in meshstack.auto.tfvars file, no command-line args needed
if err = tf.Apply(tfcmd.ctx); err != nil {
tfcmd.fail(err)
return
if tfcmd.params.planArtifactUrl != "" {
// This APPLY is linked to a predecessor DETECT run: instead of re-planning we apply the
// EXACT terraform plan that was previewed during that dry-run. The bytes are downloaded
// from the runner-facing planArtifact link with the run's bearer auth.
//
// terraform note: applying a saved plan rejects variable INPUT supplied via the -var /
// -var-file command-line flags, but auto-loaded *.auto.tfvars files present in the working
// directory are simply ignored (the saved plan embeds the variable values). Since this
// runner supplies variables exclusively via *.auto.tfvars files (see vars()), the existing
// vars()/saveInputFiles() steps stay in place and do not conflict with the saved plan.
if err = tfcmd.applyPredecessorPlan(tf); err != nil {
tfcmd.fail(err)
return
}
} else {
// Variables are now in meshstack.auto.tfvars file, no command-line args needed
if err = tf.Apply(tfcmd.ctx); err != nil {
tfcmd.fail(err)
return
}
}

tfcmd.advanceStep(nil)
Expand All @@ -175,3 +200,51 @@ func (tfcmd *TfApplyCommand) execute() {

tfcmd.completeRun(nil)
}

// applyPredecessorPlan downloads the predecessor DETECT run's saved terraform plan and applies it
// verbatim via `terraform apply <plan>`. It must be called only after createFreshCommandWd() (which
// copies the source files into the per-run working directory) and after init, so the downloaded plan
// file and the initialized .terraform directory coexist. The plan bytes are written to the same
// <wd>/plan.tfplan path that the DETECT path produces.
//
// Known limitation — provider version drift: APPLY runs in a fresh working directory and re-runs
// `terraform init` (with -upgrade, see GenericTfCmd.init), so it re-selects provider versions
// independently of the DETECT run that produced this plan. If a provider publishes a new release
// between the dry-run and the approval, terraform will reject the saved plan because its embedded
// provider versions no longer match the freshly installed ones. This is inherent to applying a
// saved plan across separate runs without persisting the predecessor's .terraform.lock.hcl. We do
// not attempt to re-plan transparently; instead the apply below fails with an actionable message
// telling the user to re-run the dry-run. Committing .terraform.lock.hcl in the building block does
// not by itself avoid this, since init still runs with -upgrade.
func (tfcmd *TfApplyCommand) applyPredecessorPlan(tf TfFacade) error {
tfcmd.Println("Applying predecessor plan artifact instead of re-planning.")

planFile := tfcmd.runContextInfo.artifactFilePath
f, err := os.OpenFile(planFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create predecessor plan artifact file %s: %w", planFile, err)
}
// Stream the download straight to disk so a large terraform plan is never fully buffered in RAM.
if err := tfcmd.runApi.DownloadPredecessorArtifact(tfcmd.params.planArtifactUrl, f); err != nil {
f.Close()
// A planArtifact link was handed out only when the predecessor plan is genuinely available,
// so a download failure here means the previewed plan can no longer be retrieved. Fail the
// run rather than silently falling back to a fresh apply.
return fmt.Errorf("failed to download the previewed terraform plan for this approval: %w. "+
"The dry-run that produced this plan must be re-run before the change can be applied", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("failed to write predecessor plan artifact to %s: %w", planFile, err)
}
tfcmd.Printfln("Wrote predecessor plan artifact to %s", planFile)

if err := tf.Apply(tfcmd.ctx, tfexec.DirOrPlan(planFile)); err != nil {
// terraform rejects a saved plan whose state/config drifted since the dry-run
// (e.g. "Saved plan is stale" / provider or state changes). Do not silently re-plan.
return fmt.Errorf("applying the previewed terraform plan failed: %w. "+
"The previewed plan is no longer valid — the underlying state, configuration, or providers "+
"changed since the dry-run. Please re-run the dry-run to produce and approve a fresh plan", err)
}

return nil
}
2 changes: 2 additions & 0 deletions tf-block-runner/tfrun/tfcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type TfCmdParams struct {
source *GitSource
preRunScript *string
runMode string
// planArtifactUrl is copied from Run.PlanArtifactUrl; empty means plain apply.
planArtifactUrl string
}

// GenericTfCmd implements generic functionality all TF commands re-use
Expand Down
Loading
Loading