Skip to content

Commit 8d86feb

Browse files
committed
feat: log resource drift as warning in build logs
Signed-off-by: Danny Kopping <dannykopping@gmail.com>
1 parent dd15026 commit 8d86feb

File tree

2 files changed

+190
-13
lines changed

2 files changed

+190
-13
lines changed

provisioner/terraform/executor.go

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -322,20 +322,12 @@ func (e *executor) plan(ctx, killCtx context.Context, env, vars []string, logr l
322322

323323
// When a prebuild claim attempt is made, log a warning if a resource is due to be replaced, since this will obviate
324324
// the point of prebuilding if the expensive resource is replaced once claimed!
325-
var (
326-
isPrebuildClaimAttempt = !destroy && metadata.GetPrebuiltWorkspaceBuildStage().IsPrebuiltWorkspaceClaim()
327-
resReps []*proto.ResourceReplacement
328-
)
325+
// We now also log drift for all builds to help with debugging resource replacements.
326+
var resReps []*proto.ResourceReplacement
329327
if repsFromPlan := findResourceReplacements(plan); len(repsFromPlan) > 0 {
330-
if isPrebuildClaimAttempt {
331-
// TODO(dannyk): we should log drift always (not just during prebuild claim attempts); we're validating that this output
332-
// will not be overwhelming for end-users, but it'll certainly be super valuable for template admins
333-
// to diagnose this resource replacement issue, at least.
334-
// Once prebuilds moves out of beta, consider deleting this condition.
335-
336-
// Lock held before calling (see top of method).
337-
e.logDrift(ctx, killCtx, planfilePath, logr)
338-
}
328+
// Log drift details for all builds to help with debugging resource replacements.
329+
// Lock held before calling (see top of method).
330+
e.logDrift(ctx, killCtx, planfilePath, logr)
339331

340332
resReps = make([]*proto.ResourceReplacement, 0, len(repsFromPlan))
341333
for n, p := range repsFromPlan {

provisioner/terraform/executor_internal_test.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
package terraform
22

33
import (
4+
"context"
45
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"sync"
510
"testing"
611

712
tfjson "github.com/hashicorp/terraform-json"
813
"github.com/stretchr/testify/require"
14+
"go.opentelemetry.io/otel/trace/noop"
915

16+
"github.com/coder/coder/v2/coderd/database"
1017
"github.com/coder/coder/v2/provisionersdk/proto"
18+
"github.com/coder/coder/v2/testutil"
1119
)
1220

1321
type mockLogger struct {
@@ -173,3 +181,180 @@ func TestOnlyDataResources(t *testing.T) {
173181
})
174182
}
175183
}
184+
185+
func TestLogDrift_WithRealTerraformPlan(t *testing.T) {
186+
t.Parallel()
187+
188+
logger := testutil.Logger(t)
189+
tmpDir := t.TempDir()
190+
191+
binPath, err := Install(t.Context(), logger, true, tmpDir, TerraformVersion)
192+
require.NoError(t, err)
193+
194+
tfConfig := `
195+
terraform {
196+
required_providers {
197+
local = {
198+
source = "hashicorp/local"
199+
version = "~> 2.0"
200+
}
201+
}
202+
}
203+
204+
resource "local_file" "test_file" {
205+
content = "initial content"
206+
filename = "test.txt"
207+
}
208+
`
209+
210+
tfFile := filepath.Join(tmpDir, "main.tf")
211+
require.NoError(t, os.WriteFile(tfFile, []byte(tfConfig), 0o644))
212+
213+
// Create a minimal server for the executor.
214+
mockSrv := &server{
215+
logger: logger,
216+
execMut: &sync.Mutex{},
217+
tracer: noop.NewTracerProvider().Tracer("test"),
218+
}
219+
220+
e := &executor{
221+
logger: logger,
222+
binaryPath: binPath,
223+
workdir: tmpDir,
224+
mut: mockSrv.execMut,
225+
server: mockSrv,
226+
timings: newTimingAggregator(database.ProvisionerJobTimingStagePlan),
227+
}
228+
229+
// These contexts must be explicitly separate from the test context.
230+
// We have a log message which prints when these contexts are canceled (or when the test completes if using t.Context()),
231+
// and this log output would be confusing to the casual reader, while innocuous.
232+
// See interruptCommandOnCancel in executor.go.
233+
ctx := context.Background()
234+
killCtx := context.Background()
235+
236+
var mockSink mockLogger
237+
err = e.init(ctx, killCtx, &mockSink)
238+
require.NoError(t, err)
239+
240+
// Create initial plan to establish state.
241+
_, err = e.plan(ctx, killCtx, e.basicEnv(), []string{}, &mockSink, &proto.Metadata{
242+
WorkspaceTransition: proto.WorkspaceTransition_START,
243+
})
244+
require.NoError(t, err)
245+
246+
// Apply the plan to create initial state.
247+
_, err = e.apply(ctx, killCtx, e.basicEnv(), &mockSink)
248+
require.NoError(t, err)
249+
250+
// Now modify the terraform configuration to cause drift.
251+
driftConfig := `
252+
terraform {
253+
required_providers {
254+
local = {
255+
source = "hashicorp/local"
256+
version = "~> 2.0"
257+
}
258+
}
259+
}
260+
261+
resource "local_file" "test_file" {
262+
content = "changed content that forces replacement"
263+
filename = "test.txt"
264+
}
265+
`
266+
267+
// Write the modified configuration.
268+
require.NoError(t, os.WriteFile(tfFile, []byte(driftConfig), 0o644))
269+
270+
// Create a new plan that will show the drift/replacement.
271+
driftLogger := &mockLogger{}
272+
planResult, err := e.plan(ctx, killCtx, e.basicEnv(), []string{}, driftLogger, &proto.Metadata{
273+
WorkspaceTransition: proto.WorkspaceTransition_START,
274+
})
275+
require.NoError(t, err)
276+
277+
// Verify we detected resource replacements (this triggers logDrift).
278+
require.NotEmpty(t, planResult.ResourceReplacements, "Should detect resource replacements that trigger drift logging")
279+
280+
// Verify that drift logs were captured.
281+
require.NotEmpty(t, driftLogger.logs, "logDrift should produce log output")
282+
283+
// Check that we have logs showing the resource replacement(s).
284+
var (
285+
foundReplacementLog, foundInfoLogs, foundWarnLogs bool
286+
)
287+
288+
for _, log := range driftLogger.logs {
289+
t.Logf("[%s] %s", log.Level.String(), log.Output)
290+
291+
if strings.Contains(log.Output, "# forces replacement") {
292+
foundReplacementLog = true
293+
require.Equal(t, proto.LogLevel_WARN, log.Level, "Lines containing '# forces replacement' should be logged at WARN level")
294+
foundWarnLogs = true
295+
}
296+
297+
if log.Level == proto.LogLevel_INFO {
298+
foundInfoLogs = true
299+
}
300+
}
301+
302+
// Verify we found the expected log types.
303+
require.True(t, foundReplacementLog, "Should find log lines containing '# forces replacement'")
304+
require.True(t, foundInfoLogs, "Should find INFO level logs showing the drift details")
305+
require.True(t, foundWarnLogs, "Should find WARN level logs for resource replacements")
306+
307+
// Verify that the drift shows the resource change.
308+
logOutput := strings.Join(func() []string {
309+
var outputs []string
310+
for _, log := range driftLogger.logs {
311+
outputs = append(outputs, log.Output)
312+
}
313+
return outputs
314+
}(), "\n")
315+
316+
require.Contains(t, logOutput, "local_file.test_file", "Drift logs should mention the specific resource")
317+
}
318+
319+
func TestResourceReplaceLogWriter(t *testing.T) {
320+
t.Parallel()
321+
322+
var logr mockLogger
323+
logger := testutil.Logger(t)
324+
writer, doneLogging := resourceReplaceLogWriter(&logr, logger)
325+
326+
// Test input with both normal lines and replacement lines.
327+
testInput := ` # local_file.test_file will be replaced
328+
-/+ resource "local_file" "test_file" {
329+
~ content = "initial content" -> "changed content" # forces replacement
330+
~ filename = "test.txt"
331+
id = "1234567890"
332+
}
333+
334+
Plan: 1 to add, 0 to change, 1 to destroy.`
335+
336+
_, err := writer.Write([]byte(testInput))
337+
require.NoError(t, err)
338+
err = writer.Close()
339+
require.NoError(t, err)
340+
<-doneLogging
341+
342+
// Verify the logs
343+
require.NotEmpty(t, logr.logs, "Should produce log output")
344+
345+
var foundReplacementWarn, foundInfoLogs bool
346+
347+
for _, log := range logr.logs {
348+
t.Logf("[%s] %s", log.Level.String(), log.Output)
349+
350+
if strings.Contains(log.Output, "# forces replacement") {
351+
require.Equal(t, proto.LogLevel_WARN, log.Level, "Lines containing '# forces replacement' should be WARN level")
352+
foundReplacementWarn = true
353+
} else if log.Level == proto.LogLevel_INFO {
354+
foundInfoLogs = true
355+
}
356+
}
357+
358+
require.True(t, foundReplacementWarn, "Should find WARN level log for '# forces replacement' line")
359+
require.True(t, foundInfoLogs, "Should find INFO level logs for other lines")
360+
}

0 commit comments

Comments
 (0)