github.com/myhau/pulumi/pkg/v3@v3.70.2-0.20221116134521-f2775972e587/codegen/testing/test/program_driver.go (about) 1 package test 2 3 import ( 4 "bufio" 5 "bytes" 6 "fmt" 7 "io" 8 "os" 9 "path/filepath" 10 "runtime" 11 "strings" 12 "testing" 13 14 "github.com/blang/semver" 15 "github.com/hashicorp/hcl/v2" 16 "github.com/stretchr/testify/assert" 17 "github.com/stretchr/testify/require" 18 19 "github.com/pulumi/pulumi/pkg/v3/codegen" 20 "github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/syntax" 21 "github.com/pulumi/pulumi/pkg/v3/codegen/pcl" 22 "github.com/pulumi/pulumi/pkg/v3/codegen/testing/utils" 23 "github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil" 24 "github.com/pulumi/pulumi/sdk/v3/go/common/workspace" 25 ) 26 27 const ( 28 transpiledExamplesDir = "transpiled_examples" 29 ) 30 31 func transpiled(dir string) string { 32 return filepath.Join(transpiledExamplesDir, dir) 33 } 34 35 var allProgLanguages = codegen.NewStringSet("dotnet", "python", "go", "nodejs") 36 37 type ProgramTest struct { 38 Directory string 39 Description string 40 Skip codegen.StringSet 41 ExpectNYIDiags codegen.StringSet 42 SkipCompile codegen.StringSet 43 BindOptions []pcl.BindOption 44 MockPluginVersions map[string]string 45 } 46 47 var testdataPath = filepath.Join("..", "testing", "test", "testdata") 48 49 // Get batch number k (base-1 indexed) of tests out of n batches total. 50 func ProgramTestBatch(k, n int) []ProgramTest { 51 start := ((k - 1) * len(PulumiPulumiProgramTests)) / n 52 end := ((k) * len(PulumiPulumiProgramTests)) / n 53 return PulumiPulumiProgramTests[start:end] 54 } 55 56 var PulumiPulumiProgramTests = []ProgramTest{ 57 { 58 Directory: "assets-archives", 59 Description: "Assets and archives", 60 SkipCompile: codegen.NewStringSet("go"), 61 }, 62 { 63 Directory: "synthetic-resource-properties", 64 Description: "Synthetic resource properties", 65 SkipCompile: codegen.NewStringSet("nodejs", "dotnet", "go"), // not a real package 66 }, 67 { 68 Directory: "aws-s3-folder", 69 Description: "AWS S3 Folder", 70 ExpectNYIDiags: allProgLanguages.Except("go"), 71 SkipCompile: allProgLanguages.Except("dotnet"), 72 // Blocked on python: TODO[pulumi/pulumi#8062]: Re-enable this test. 73 // Blocked on go: 74 // TODO[pulumi/pulumi#8064] 75 // TODO[pulumi/pulumi#8065] 76 // Blocked on nodejs: TODO[pulumi/pulumi#8063] 77 }, 78 { 79 Directory: "aws-eks", 80 Description: "AWS EKS", 81 SkipCompile: codegen.NewStringSet("nodejs"), 82 // Blocked on nodejs: TODO[pulumi/pulumi#8067] 83 }, 84 { 85 Directory: "aws-fargate", 86 Description: "AWS Fargate", 87 88 // TODO[pulumi/pulumi#8440] 89 SkipCompile: codegen.NewStringSet("go"), 90 }, 91 { 92 Directory: "aws-s3-logging", 93 Description: "AWS S3 with logging", 94 SkipCompile: allProgLanguages.Except("python").Except("dotnet"), 95 // Blocked on nodejs: TODO[pulumi/pulumi#8068] 96 // Flaky in go: TODO[pulumi/pulumi#8123] 97 }, 98 { 99 Directory: "aws-iam-policy", 100 Description: "AWS IAM Policy", 101 }, 102 { 103 Directory: "python-regress-10914", 104 Description: "Python regression test for #10914", 105 Skip: allProgLanguages.Except("python"), 106 }, 107 { 108 Directory: "aws-optionals", 109 Description: "AWS get invoke with nested object constructor that takes an optional string", 110 // Testing Go behavior exclusively: 111 Skip: allProgLanguages.Except("go"), 112 }, 113 { 114 Directory: "aws-webserver", 115 Description: "AWS Webserver", 116 SkipCompile: codegen.NewStringSet("go"), 117 // Blocked on go: TODO[pulumi/pulumi#8070] 118 }, 119 { 120 Directory: "simple-range", 121 Description: "Simple range as int expression translation", 122 BindOptions: []pcl.BindOption{pcl.AllowMissingVariables}, 123 }, 124 { 125 Directory: "azure-native", 126 Description: "Azure Native", 127 Skip: codegen.NewStringSet("go"), 128 // Blocked on TODO[pulumi/pulumi#8123] 129 SkipCompile: codegen.NewStringSet("go", "nodejs", "dotnet"), 130 // Blocked on go: 131 // TODO[pulumi/pulumi#8072] 132 // TODO[pulumi/pulumi#8073] 133 // TODO[pulumi/pulumi#8074] 134 // Blocked on nodejs: 135 // TODO[pulumi/pulumi#8075] 136 }, 137 { 138 Directory: "azure-sa", 139 Description: "Azure SA", 140 }, 141 { 142 Directory: "kubernetes-operator", 143 Description: "K8s Operator", 144 }, 145 { 146 Directory: "kubernetes-pod", 147 Description: "K8s Pod", 148 SkipCompile: codegen.NewStringSet("go", "nodejs"), 149 // Blocked on go: 150 // TODO[pulumi/pulumi#8073] 151 // TODO[pulumi/pulumi#8074] 152 // Blocked on nodejs: 153 // TODO[pulumi/pulumi#8075] 154 }, 155 { 156 Directory: "kubernetes-template", 157 Description: "K8s Template", 158 }, 159 { 160 Directory: "random-pet", 161 Description: "Random Pet", 162 }, 163 { 164 Directory: "aws-secret", 165 Description: "Secret", 166 }, 167 { 168 Directory: "functions", 169 Description: "Functions", 170 }, 171 { 172 Directory: "output-funcs-aws", 173 Description: "Output Versioned Functions", 174 }, 175 { 176 Directory: "third-party-package", 177 Description: "Ensuring correct imports for third party packages", 178 // compiling and type checking involves downloading the real package to 179 // check against. Because we are checking against the "other" package 180 // (which doesn't exist), this does not work. 181 SkipCompile: codegen.NewStringSet("nodejs", "dotnet", "go"), 182 }, 183 { 184 Directory: "invalid-go-sprintf", 185 Description: "Regress invalid Go", 186 Skip: codegen.NewStringSet("python", "nodejs", "dotnet"), 187 }, 188 { 189 Directory: "typed-enum", 190 Description: "Supply strongly typed enums", 191 Skip: codegen.NewStringSet(golang), 192 }, 193 { 194 Directory: "pulumi-stack-reference", 195 Description: "StackReference as resource", 196 }, 197 { 198 Directory: "python-resource-names", 199 Description: "Repro for #9357", 200 Skip: codegen.NewStringSet("go", "nodejs", "dotnet"), 201 }, 202 { 203 Directory: "logical-name", 204 Description: "Logical names", 205 }, 206 { 207 Directory: "aws-lambda", 208 Description: "AWS Lambdas", 209 // We have special testing for this case because lambda is a python keyword. 210 Skip: codegen.NewStringSet("go", "nodejs", "dotnet"), 211 }, 212 { 213 Directory: "discriminated-union", 214 Description: "Discriminated Unions for choosing an input type", 215 Skip: codegen.NewStringSet("go"), 216 // Blocked on go: TODO[pulumi/pulumi#10834] 217 }, 218 } 219 220 var PulumiPulumiYAMLProgramTests = []ProgramTest{ 221 // PCL files from pulumi/yaml transpiled examples 222 { 223 Directory: transpiled("aws-eks"), 224 Description: "AWS EKS", 225 Skip: codegen.NewStringSet("go", "nodejs", "dotnet"), 226 }, 227 { 228 Directory: transpiled("aws-static-website"), 229 Description: "AWS static website", 230 Skip: codegen.NewStringSet("go", "nodejs", "dotnet"), 231 BindOptions: []pcl.BindOption{pcl.SkipResourceTypechecking}, 232 }, 233 { 234 Directory: transpiled("awsx-fargate"), 235 Description: "AWSx Fargate", 236 Skip: codegen.NewStringSet("dotnet", "nodejs", "go"), 237 }, 238 { 239 Directory: transpiled("azure-app-service"), 240 Description: "Azure App Service", 241 Skip: codegen.NewStringSet("go", "dotnet"), 242 BindOptions: []pcl.BindOption{pcl.SkipResourceTypechecking}, 243 }, 244 { 245 Directory: transpiled("azure-container-apps"), 246 Description: "Azure Container Apps", 247 Skip: codegen.NewStringSet("go", "nodejs", "dotnet", "python"), 248 }, 249 { 250 Directory: transpiled("azure-static-website"), 251 Description: "Azure static website", 252 Skip: codegen.NewStringSet("go", "nodejs", "dotnet", "python"), 253 }, 254 { 255 Directory: transpiled("cue-eks"), 256 Description: "Cue EKS", 257 Skip: codegen.NewStringSet("go", "nodejs", "dotnet"), 258 }, 259 { 260 Directory: transpiled("cue-random"), 261 Description: "Cue random", 262 }, 263 { 264 Directory: transpiled("cue-static-web-app"), 265 Description: "Cue static web app", 266 }, 267 { 268 Directory: transpiled("getting-started"), 269 Description: "Getting started", 270 }, 271 { 272 Directory: transpiled("kubernetes"), 273 Description: "Kubernetes", 274 Skip: codegen.NewStringSet("go"), 275 // PCL resource attribute type checking doesn't handle missing `const` attributes. 276 // 277 BindOptions: []pcl.BindOption{pcl.SkipResourceTypechecking}, 278 }, 279 { 280 Directory: transpiled("pulumi-variable"), 281 Description: "Pulumi variable", 282 Skip: codegen.NewStringSet("go", "nodejs", "dotnet"), 283 }, 284 { 285 Directory: transpiled("random"), 286 Description: "Random", 287 Skip: codegen.NewStringSet("nodejs"), 288 }, 289 { 290 Directory: transpiled("readme"), 291 Description: "README", 292 Skip: codegen.NewStringSet("go", "dotnet"), 293 }, 294 { 295 Directory: transpiled("stackreference-consumer"), 296 Description: "Stack reference consumer", 297 Skip: codegen.NewStringSet("go", "nodejs", "dotnet"), 298 }, 299 { 300 Directory: transpiled("stackreference-producer"), 301 Description: "Stack reference producer", 302 Skip: codegen.NewStringSet("go", "dotnet"), 303 }, 304 { 305 Directory: transpiled("webserver-json"), 306 Description: "Webserver JSON", 307 Skip: codegen.NewStringSet("go", "dotnet", "python"), 308 }, 309 { 310 Directory: transpiled("webserver"), 311 Description: "Webserver", 312 Skip: codegen.NewStringSet("go", "dotnet", "python"), 313 }, 314 } 315 316 // Checks that a generated program is correct 317 // 318 // The arguments are to be read: 319 // (Testing environment, path to generated code, set of dependencies) 320 type CheckProgramOutput = func(*testing.T, string, codegen.StringSet) 321 322 // Generates a program from a pcl.Program 323 type GenProgram = func(program *pcl.Program) (map[string][]byte, hcl.Diagnostics, error) 324 325 // Generates a project from a pcl.Program 326 type GenProject = func(directory string, project workspace.Project, program *pcl.Program) error 327 328 type ProgramCodegenOptions struct { 329 Language string 330 Extension string 331 OutputFile string 332 Check CheckProgramOutput 333 GenProgram GenProgram 334 TestCases []ProgramTest 335 336 // For generating a full project 337 IsGenProject bool 338 GenProject GenProject 339 // Maps a test file (i.e. "aws-resource-options") to a struct containing a package 340 // (i.e. "github.com/pulumi/pulumi-aws/sdk/v5", "pulumi-aws) and its 341 // version prefixed by an operator (i.e. " v5.11.0", ==5.11.0") 342 ExpectedVersion map[string]PkgVersionInfo 343 DependencyFile string 344 } 345 346 type PkgVersionInfo struct { 347 Pkg string 348 OpAndVersion string 349 } 350 351 // TestProgramCodegen runs the complete set of program code generation tests against a particular 352 // language's code generator. 353 // 354 // A program code generation test consists of a PCL file (.pp extension) and a set of expected outputs 355 // for each language. 356 // 357 // The PCL file is the only piece that must be manually authored. Once the schema has been written, the expected outputs 358 // can be generated by running `PULUMI_ACCEPT=true go test ./..." from the `pkg/codegen` directory. 359 // nolint: revive 360 func TestProgramCodegen( 361 t *testing.T, 362 testcase ProgramCodegenOptions, 363 ) { 364 if runtime.GOOS == "windows" { 365 t.Skip("TestProgramCodegen is skipped on Windows") 366 } 367 368 assert.NotNil(t, testcase.TestCases, "Caller must provide test cases") 369 pulumiAccept := cmdutil.IsTruthy(os.Getenv("PULUMI_ACCEPT")) 370 skipCompile := cmdutil.IsTruthy(os.Getenv("PULUMI_SKIP_COMPILE_TEST")) 371 372 for _, tt := range testcase.TestCases { 373 tt := tt // avoid capturing loop variable 374 t.Run(tt.Directory, func(t *testing.T) { 375 t.Parallel() 376 var err error 377 if tt.Skip.Has(testcase.Language) { 378 t.Skip() 379 return 380 } 381 382 expectNYIDiags := tt.ExpectNYIDiags.Has(testcase.Language) 383 384 testDir := filepath.Join(testdataPath, tt.Directory+"-pp") 385 pclFile := filepath.Join(testDir, tt.Directory+".pp") 386 if strings.HasPrefix(tt.Directory, transpiledExamplesDir) { 387 pclFile = filepath.Join(testDir, filepath.Base(tt.Directory)+".pp") 388 } 389 testDir = filepath.Join(testDir, testcase.Language) 390 err = os.MkdirAll(testDir, 0700) 391 if err != nil && !os.IsExist(err) { 392 t.Fatalf("Failed to create %q: %s", testDir, err) 393 } 394 395 contents, err := os.ReadFile(pclFile) 396 if err != nil { 397 t.Fatalf("could not read %v: %v", pclFile, err) 398 } 399 400 expectedFile := filepath.Join(testDir, tt.Directory+"."+testcase.Extension) 401 if strings.HasPrefix(tt.Directory, transpiledExamplesDir) { 402 expectedFile = filepath.Join(testDir, filepath.Base(tt.Directory)+"."+testcase.Extension) 403 } 404 expected, err := os.ReadFile(expectedFile) 405 if err != nil && !pulumiAccept { 406 t.Fatalf("could not read %v: %v", expectedFile, err) 407 } 408 409 parser := syntax.NewParser() 410 err = parser.ParseFile(bytes.NewReader(contents), tt.Directory+".pp") 411 if err != nil { 412 t.Fatalf("could not read %v: %v", pclFile, err) 413 } 414 if parser.Diagnostics.HasErrors() { 415 t.Fatalf("failed to parse files: %v", parser.Diagnostics) 416 } 417 418 opts := []pcl.BindOption{ 419 pcl.PluginHost(utils.NewHost(testdataPath)), 420 } 421 opts = append(opts, tt.BindOptions...) 422 423 program, diags, err := pcl.BindProgram(parser.Files, opts...) 424 if err != nil { 425 t.Fatalf("could not bind program: %v", err) 426 } 427 if diags.HasErrors() { 428 t.Fatalf("failed to bind program: %v", diags) 429 } 430 var files map[string][]byte 431 // generate a full project and check expected package versions 432 if testcase.IsGenProject { 433 project := workspace.Project{ 434 Name: "test", 435 Runtime: workspace.NewProjectRuntimeInfo(testcase.Language, nil), 436 } 437 err = testcase.GenProject(testDir, project, program) 438 assert.NoError(t, err) 439 440 depFilePath := filepath.Join(testDir, testcase.DependencyFile) 441 outfilePath := filepath.Join(testDir, testcase.OutputFile) 442 CheckVersion(t, tt.Directory, depFilePath, testcase.ExpectedVersion) 443 GenProjectCleanUp(t, testDir, depFilePath, outfilePath) 444 445 } 446 files, diags, err = testcase.GenProgram(program) 447 assert.NoError(t, err) 448 if expectNYIDiags { 449 var tmpDiags hcl.Diagnostics 450 for _, d := range diags { 451 if !strings.HasPrefix(d.Summary, "not yet implemented") { 452 tmpDiags = append(tmpDiags, d) 453 } 454 } 455 diags = tmpDiags 456 } 457 if diags.HasErrors() { 458 t.Fatalf("failed to generate program: %v", diags) 459 } 460 461 if pulumiAccept { 462 err := os.WriteFile(expectedFile, files[testcase.OutputFile], 0600) 463 require.NoError(t, err) 464 } else { 465 assert.Equal(t, string(expected), string(files[testcase.OutputFile])) 466 } 467 if !skipCompile && testcase.Check != nil && !tt.SkipCompile.Has(testcase.Language) { 468 extraPulumiPackages := codegen.NewStringSet() 469 for _, n := range program.Nodes { 470 if r, isResource := n.(*pcl.Resource); isResource { 471 pkg, _, _, _ := r.DecomposeToken() 472 if pkg != "pulumi" { 473 extraPulumiPackages.Add(pkg) 474 } 475 } 476 } 477 testcase.Check(t, expectedFile, extraPulumiPackages) 478 } 479 }) 480 } 481 } 482 483 // CheckVersion checks for an expected package version 484 // Todo: support checking multiple package expected versions 485 func CheckVersion(t *testing.T, dir, depFilePath string, expectedVersionMap map[string]PkgVersionInfo) { 486 depFile, err := os.Open(depFilePath) 487 require.NoError(t, err) 488 defer depFile.Close() 489 490 // Splits on newlines by default. 491 scanner := bufio.NewScanner(depFile) 492 493 match := false 494 expectedPkg, expectedVersion := strings.TrimSpace(expectedVersionMap[dir].Pkg), 495 strings.TrimSpace(expectedVersionMap[dir].OpAndVersion) 496 for scanner.Scan() { 497 line := scanner.Text() 498 if strings.Contains(line, expectedPkg) { 499 line = strings.TrimSpace(line) 500 actualVersion := strings.TrimPrefix(line, expectedPkg) 501 actualVersion = strings.TrimSpace(actualVersion) 502 expectedVersion = strings.Trim(expectedVersion, "v:^/> ") 503 actualVersion = strings.Trim(actualVersion, "v:^/> ") 504 if expectedVersion == actualVersion { 505 match = true 506 break 507 } 508 actualSemver, err := semver.Make(actualVersion) 509 if err == nil { 510 continue 511 } 512 expectedSemver, _ := semver.Make(expectedVersion) 513 if actualSemver.Compare(expectedSemver) >= 0 { 514 match = true 515 break 516 } 517 } 518 } 519 require.Truef(t, match, "Did not find expected package version pair (%q,%q). Searched in:\n%s", 520 expectedPkg, expectedVersion, newLazyStringer(func() string { 521 // Reset the read on the file 522 _, err := depFile.Seek(0, io.SeekStart) 523 require.NoError(t, err) 524 buf := new(strings.Builder) 525 _, err = io.Copy(buf, depFile) 526 require.NoError(t, err) 527 return buf.String() 528 }).String()) 529 } 530 531 func GenProjectCleanUp(t *testing.T, dir, depFilePath, outfilePath string) { 532 os.Remove(depFilePath) 533 os.Remove(outfilePath) 534 os.Remove(dir + "/.gitignore") 535 os.Remove(dir + "/Pulumi.yaml") 536 } 537 538 type lazyStringer struct { 539 cache string 540 f func() string 541 } 542 543 func (l lazyStringer) String() string { 544 if l.cache == "" { 545 l.cache = l.f() 546 } 547 return l.cache 548 } 549 550 // The `fmt` `%s` calls .String() if the object is not a string itself. We can delay 551 // computing expensive display logic until and unless we actually will use it. 552 func newLazyStringer(f func() string) fmt.Stringer { 553 return lazyStringer{f: f} 554 }