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  }