|
1 | 1 | package terraform
|
2 | 2 |
|
3 | 3 | import (
|
| 4 | + "context" |
4 | 5 | "encoding/json"
|
| 6 | + "os" |
| 7 | + "path/filepath" |
| 8 | + "strings" |
| 9 | + "sync" |
5 | 10 | "testing"
|
6 | 11 |
|
7 | 12 | tfjson "github.com/hashicorp/terraform-json"
|
8 | 13 | "github.com/stretchr/testify/require"
|
| 14 | + "go.opentelemetry.io/otel/trace/noop" |
9 | 15 |
|
| 16 | + "github.com/coder/coder/v2/coderd/database" |
10 | 17 | "github.com/coder/coder/v2/provisionersdk/proto"
|
| 18 | + "github.com/coder/coder/v2/testutil" |
11 | 19 | )
|
12 | 20 |
|
13 | 21 | type mockLogger struct {
|
@@ -173,3 +181,180 @@ func TestOnlyDataResources(t *testing.T) {
|
173 | 181 | })
|
174 | 182 | }
|
175 | 183 | }
|
| 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