|
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 | typemockLoggerstruct {
|
@@ -173,3 +181,180 @@ func TestOnlyDataResources(t *testing.T) {
|
173 | 181 | })
|
174 | 182 | }
|
175 | 183 | }
|
| 184 | + |
| 185 | +funcTestLogDrift_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 | +varmockSinkmockLogger |
| 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,foundWarnLogsbool |
| 286 | +) |
| 287 | + |
| 288 | +for_,log:=rangedriftLogger.logs { |
| 289 | +t.Logf("[%s] %s",log.Level.String(),log.Output) |
| 290 | + |
| 291 | +ifstrings.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 | +iflog.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 | +varoutputs []string |
| 310 | +for_,log:=rangedriftLogger.logs { |
| 311 | +outputs=append(outputs,log.Output) |
| 312 | +} |
| 313 | +returnoutputs |
| 314 | +}(),"\n") |
| 315 | + |
| 316 | +require.Contains(t,logOutput,"local_file.test_file","Drift logs should mention the specific resource") |
| 317 | +} |
| 318 | + |
| 319 | +funcTestResourceReplaceLogWriter(t*testing.T) { |
| 320 | +t.Parallel() |
| 321 | + |
| 322 | +varlogrmockLogger |
| 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 | +varfoundReplacementWarn,foundInfoLogsbool |
| 346 | + |
| 347 | +for_,log:=rangelogr.logs { |
| 348 | +t.Logf("[%s] %s",log.Level.String(),log.Output) |
| 349 | + |
| 350 | +ifstrings.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 | +}elseiflog.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 | +} |