diff --git a/.gitignore b/.gitignore index 2601c06..dab8fc9 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ override.tf.json # CLI configuration files .terraformrc -terraform.rc \ No newline at end of file +terraform.rc diff --git a/go-meshapi-client/meshapi/client.go b/go-meshapi-client/meshapi/client.go index 95821f6..c1abdab 100644 --- a/go-meshapi-client/meshapi/client.go +++ b/go-meshapi-client/meshapi/client.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "strings" ) const ( @@ -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 ( @@ -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 { diff --git a/go-meshapi-client/meshapi/client_test.go b/go-meshapi-client/meshapi/client_test.go new file mode 100644 index 0000000..0279ed8 --- /dev/null +++ b/go-meshapi-client/meshapi/client_test.go @@ -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") +} diff --git a/go-meshapi-client/meshapi/dtos.go b/go-meshapi-client/meshapi/dtos.go index 28a4d54..37d3faa 100644 --- a/go-meshapi-client/meshapi/dtos.go +++ b/go-meshapi-client/meshapi/dtos.go @@ -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 { diff --git a/tf-block-runner/tfrun/dtos.go b/tf-block-runner/tfrun/dtos.go index ba3f580..b2a2384 100644 --- a/tf-block-runner/tfrun/dtos.go +++ b/tf-block-runner/tfrun/dtos.go @@ -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 } @@ -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 } diff --git a/tf-block-runner/tfrun/run.go b/tf-block-runner/tfrun/run.go index a81f957..0300fe9 100644 --- a/tf-block-runner/tfrun/run.go +++ b/tf-block-runner/tfrun/run.go @@ -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 { diff --git a/tf-block-runner/tfrun/runapi.go b/tf-block-runner/tfrun/runapi.go index b078f86..91add31 100644 --- a/tf-block-runner/tfrun/runapi.go +++ b/tf-block-runner/tfrun/runapi.go @@ -3,6 +3,7 @@ package tfrun import ( "encoding/json" "fmt" + "io" "log" "net/http" @@ -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 { @@ -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) diff --git a/tf-block-runner/tfrun/singlerunworker.go b/tf-block-runner/tfrun/singlerunworker.go index 81a3bec..169db08 100644 --- a/tf-block-runner/tfrun/singlerunworker.go +++ b/tf-block-runner/tfrun/singlerunworker.go @@ -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: diff --git a/tf-block-runner/tfrun/tfapply.go b/tf-block-runner/tfrun/tfapply.go index 9db6cdb..e135718 100644 --- a/tf-block-runner/tfrun/tfapply.go +++ b/tf-block-runner/tfrun/tfapply.go @@ -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, } } @@ -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) @@ -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 `. 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 +// /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 +} diff --git a/tf-block-runner/tfrun/tfcmd.go b/tf-block-runner/tfrun/tfcmd.go index e3e4cb4..618af72 100644 --- a/tf-block-runner/tfrun/tfcmd.go +++ b/tf-block-runner/tfrun/tfcmd.go @@ -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 diff --git a/tf-block-runner/tfrun/tfplan_scenario_test.go b/tf-block-runner/tfrun/tfplan_scenario_test.go index bb08b44..80bbc52 100644 --- a/tf-block-runner/tfrun/tfplan_scenario_test.go +++ b/tf-block-runner/tfrun/tfplan_scenario_test.go @@ -61,6 +61,160 @@ func (suite *WorkerTestSuite) Test_DetectSucceeded_ArtifactInStatusUpdate() { assert.Equal(suite.T(), planBytes, decoded) } +// Test_ApplyWithPlanArtifact_DownloadsAndAppliesSavedPlan verifies that an APPLY run carrying a +// planArtifact link downloads the predecessor plan bytes, writes them to /plan.tfplan, and +// invokes terraform apply with a (DirOrPlan) option pointing at that saved plan instead of a plain +// re-plan+apply. +func (suite *WorkerTestSuite) Test_ApplyWithPlanArtifact_DownloadsAndAppliesSavedPlan() { + savedPlanBytes := []byte("predecessor-saved-plan-binary") + + planArtifactHref := "http://localhost/api/meshobjects/meshbuildingblockruns/run-uuid/plan-artifact" + suite.calls.fetch = mockApplyRunWithPlanArtifactFetchCall( + "https://github.com/meshcloud/meshstack-hub.git", + "modules/github/repository/buildingblock", + planArtifactHref, + ) + + downloadCalled := false + suite.calls.download = func(req *http.Request) *http.Response { + downloadCalled = true + assert.Equal(suite.T(), "application/octet-stream", req.Header.Get("Accept")) + // the run-scoped bearer token from the fetched run must be used for the download + assert.Equal(suite.T(), "Bearer test-mock-run-token-12345", req.Header.Get("Authorization")) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(savedPlanBytes)), + Header: make(http.Header), + } + } + + applyOptCount := -1 + var planOnDisk []byte + suite.tfMock.applyFunc = func(ctx context.Context, opts ...tfexec.ApplyOption) error { + applyOptCount = len(opts) + rci := ctx.Value(runInfoContextKey).(*RunContextInfo) + planOnDisk, _ = os.ReadFile(rci.artifactFilePath) + return nil + } + + updateCalls := make([]http.Request, 0) + suite.calls.update = func(req *http.Request) *http.Response { + updateCalls = append(updateCalls, *req) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer([]byte("{}"))), + Header: make(http.Header), + } + } + + suite.runWorker() + + assert.True(suite.T(), downloadCalled, "expected the predecessor plan artifact to be downloaded") + assert.Equal(suite.T(), 1, applyOptCount, "expected apply to be called with a single (DirOrPlan) option") + assert.Equal(suite.T(), savedPlanBytes, planOnDisk, "expected the downloaded plan bytes to be written to plan.tfplan") + + require.GreaterOrEqual(suite.T(), len(updateCalls), 1) + lastUpdate := updateCalls[len(updateCalls)-1] + data, err := io.ReadAll(lastUpdate.Body) + require.NoError(suite.T(), err) + var update meshapi.RunStatusUpdateDTO + require.NoError(suite.T(), json.Unmarshal(data, &update)) + assert.Equal(suite.T(), SUCCEEDED.str(), *update.Status) +} + +// Test_ApplyWithoutPlanArtifact_PlainApply is the backward-compatibility regression: an APPLY run +// with NO planArtifact link must do a plain terraform apply (no download, no DirOrPlan option). +func (suite *WorkerTestSuite) Test_ApplyWithoutPlanArtifact_PlainApply() { + suite.calls.fetch = mockValidRunDetailsFetchCall(APPLY.str(), "https://github.com/meshcloud/meshstack-hub.git", "modules/github/repository/buildingblock") + + downloadCalled := false + suite.calls.download = func(req *http.Request) *http.Response { + downloadCalled = true + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBuffer(nil)), Header: make(http.Header)} + } + + applyOptCount := -1 + suite.tfMock.applyFunc = func(ctx context.Context, opts ...tfexec.ApplyOption) error { + applyOptCount = len(opts) + return nil + } + + updateCalls := make([]http.Request, 0) + suite.calls.update = func(req *http.Request) *http.Response { + updateCalls = append(updateCalls, *req) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer([]byte("{}"))), + Header: make(http.Header), + } + } + + suite.runWorker() + + assert.False(suite.T(), downloadCalled, "plain apply must not download any plan artifact") + assert.Equal(suite.T(), 0, applyOptCount, "plain apply must call terraform apply with no plan option") + + require.GreaterOrEqual(suite.T(), len(updateCalls), 1) + lastUpdate := updateCalls[len(updateCalls)-1] + data, err := io.ReadAll(lastUpdate.Body) + require.NoError(suite.T(), err) + var update meshapi.RunStatusUpdateDTO + require.NoError(suite.T(), json.Unmarshal(data, &update)) + assert.Equal(suite.T(), SUCCEEDED.str(), *update.Status) +} + +// Test_ApplyWithPlanArtifact_DownloadFailureFailsRun verifies that when the planArtifact download +// returns a non-2xx (e.g. the artifact is gone), the run FAILS and terraform apply is never called. +func (suite *WorkerTestSuite) Test_ApplyWithPlanArtifact_DownloadFailureFailsRun() { + planArtifactHref := "http://localhost/api/meshobjects/meshbuildingblockruns/run-uuid/plan-artifact" + suite.calls.fetch = mockApplyRunWithPlanArtifactFetchCall( + "https://github.com/meshcloud/meshstack-hub.git", + "modules/github/repository/buildingblock", + planArtifactHref, + ) + + suite.calls.download = func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewBuffer([]byte("not found"))), + Header: make(http.Header), + } + } + + applyCalled := false + suite.tfMock.applyFunc = func(ctx context.Context, opts ...tfexec.ApplyOption) error { + applyCalled = true + return nil + } + + updateCalls := make([]http.Request, 0) + suite.calls.update = func(req *http.Request) *http.Response { + updateCalls = append(updateCalls, *req) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer([]byte("{}"))), + Header: make(http.Header), + } + } + + suite.runWorker() + + assert.False(suite.T(), applyCalled, "terraform apply must not be called when the plan download fails") + + require.GreaterOrEqual(suite.T(), len(updateCalls), 1) + lastUpdate := updateCalls[len(updateCalls)-1] + data, err := io.ReadAll(lastUpdate.Body) + require.NoError(suite.T(), err) + var update meshapi.RunStatusUpdateDTO + require.NoError(suite.T(), json.Unmarshal(data, &update)) + + assert.Equal(suite.T(), FAILED.str(), *update.Status) + executeTf := findStep(suite.T(), update, StepExecuteTf) + assert.Equal(suite.T(), FAILED.str(), *executeTf.Status) + require.NotNil(suite.T(), executeTf.UserMessage) + assert.Contains(suite.T(), *executeTf.UserMessage, "previewed terraform plan") +} + // Test_DetectFailed_WhenPlanFileNotWritten verifies that the run fails when // terraform plan "succeeds" but does not produce the expected plan file. func (suite *WorkerTestSuite) Test_DetectFailed_WhenPlanFileNotWritten() { diff --git a/tf-block-runner/tfrun/worker.go b/tf-block-runner/tfrun/worker.go index 697d978..7245e8a 100644 --- a/tf-block-runner/tfrun/worker.go +++ b/tf-block-runner/tfrun/worker.go @@ -149,6 +149,7 @@ func (w *Worker) workRoutine(ctx context.Context, run *Run, wg *sync.WaitGroup, source: run.Source, preRunScript: run.PreRunScript, runMode: run.Behavior.str(), + planArtifactUrl: run.PlanArtifactUrl, } var tfCommand TfCmd @@ -156,7 +157,7 @@ func (w *Worker) workRoutine(ctx context.Context, run *Run, wg *sync.WaitGroup, 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) diff --git a/tf-block-runner/tfrun/worker_scenario_test.go b/tf-block-runner/tfrun/worker_scenario_test.go index 7e3c191..7ba2ed8 100644 --- a/tf-block-runner/tfrun/worker_scenario_test.go +++ b/tf-block-runner/tfrun/worker_scenario_test.go @@ -32,7 +32,7 @@ type WorkerTestSuite struct { } type MockRunApiCalls struct { - fetch, register, update func(*http.Request) *http.Response + fetch, register, update, download func(*http.Request) *http.Response } func noopCall(req *http.Request) *http.Response { @@ -43,6 +43,10 @@ func noopCall(req *http.Request) *http.Response { func (suite *WorkerTestSuite) scenarioClientBehavior(req *http.Request) *http.Response { switch { + // this is the predecessor plan-artifact download call + case req.Method == http.MethodGet && strings.Contains(req.URL.Path, "/plan-artifact"): + return suite.calls.download(req) + // this is the "status update" call case req.Method == http.MethodPatch && strings.Contains(req.URL.Path, "/status/source"): return suite.calls.update(req) @@ -105,6 +109,7 @@ func (suite *WorkerTestSuite) SetupTest() { fetch: noopCall, register: noopCall, update: noopCall, + download: noopCall, } suite.tfMock.initMockFuncs() // reset to default mock behavior @@ -122,9 +127,9 @@ func (suite *WorkerTestSuite) SetupTest() { workerOut: make(chan workerToken, 2), runApi: &RunApiClient{ rid: "scenario-runner", - baseURL: "", + baseURL: "http://localhost", auth: scenarioAuth, - client: meshapi.NewClientWithHTTP("", "scenario-runner", scenarioAuth, mockHC), + client: meshapi.NewClientWithHTTP("http://localhost", "scenario-runner", scenarioAuth, mockHC), httpClient: mockHC, }, log: log.New(io.Discard, "", log.LstdFlags), @@ -605,6 +610,55 @@ func mockValidRunDetailsFetchCall(behavior, repo, path string) func(_ *http.Requ } } +func mockApplyRunWithPlanArtifactFetchCall(repo, repoPath, planArtifactHref string) func(_ *http.Request) *http.Response { + return func(_ *http.Request) *http.Response { + implDTO := meshapi.TerraformImplementation{ + TerraformVersion: DEFAULT_TF_VER, + RepositoryUrl: repo, + RepositoryPath: p(repoPath), + Async: false, + } + implJSON, _ := json.Marshal(implDTO) + body, _ := json.Marshal( + &meshapi.RunDetailsDTO{ + ApiVersion: "v1", + Kind: "MeshBuildingBlockRun", + Metadata: meshapi.RunMetaDTO{Uuid: "run-uuid"}, + Spec: meshapi.RunSpecDTO{ + RunNumber: 1, + Behavior: APPLY.str(), + RunToken: "test-mock-run-token-12345", + BuildingBlock: meshapi.BuildingBlockSpecDTO{ + Uuid: "block-uuid", + Spec: meshapi.BuildingBlockDetailsSpecDTO{ + DisplayName: "Test-BuildingBlock", + Inputs: make([]meshapi.BuildingBlockInputSpecDTO, 0), + }, + }, + Definition: meshapi.DefinitionSpecDTO{ + Uuid: "definition-uuid", + Spec: meshapi.DefinitionDetailsSpecDTO{ + Version: 1, + Implementation: implJSON, + }, + }, + }, + Links: meshapi.LinksDTO{ + PlanArtifact: meshapi.LinkDTO{Href: planArtifactHref}, + }, + }, + ) + header := make(http.Header) + header.Add("Content-Type", meshapi.BlockRunMediaTypeV1) + + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(body)), + Header: header, + } + } +} + // returns pointer of given value to be able to inline value without var usage func p[T any](v T) *T { return &v