internal/acceptance/acceptance.go (1,078 lines of code) (raw):

// Copyright 2021 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package acceptance implements functions for builder acceptance tests. // // These tests only run locally and require the following command-line tools: // * pack: https://buildpacks.io/docs/install-pack/ // * container-structure-test: https://github.com/GoogleContainerTools/container-structure-test#installation package acceptance import ( "bytes" "encoding/json" "flag" "fmt" "io" "io/ioutil" "log" "math/rand" "net/http" "os" "os/exec" "path" "path/filepath" "regexp" "strconv" "strings" "testing" "time" "github.com/BurntSushi/toml" "github.com/Masterminds/semver" "github.com/rs/xid" "github.com/GoogleCloudPlatform/buildpacks/internal/checktools" ) const ( // cacheHitMessage is emitted by ctx.CacheHit(). Must match gcpbuildpack value. cacheHitMessage = "***** CACHE HIT:" // cacheMissMessage is emitted by ctx.CacheMiss(). Must match gcpbuildpack value. cacheMissMessage = "***** CACHE MISS:" ) var ( testData string // Path to directory or archive containing source test data. structureTestConfig string // Path to container test configuration file. builderSource string // Path to directory or archive containing builder source. builderImage string // Name of the builder image to test; takes precedence over builderSource. runImageOverride string // Name of the run image to use during the test. This takes preference over the run-image defined in the builder.toml. builderPrefix string // Prefix for created builder image. keepArtifacts bool // If true, keeps intermediate artifacts such as application images. packBin string // Path to pack binary. structureBin string // Path to container-structure-test binary. lifecycle string // Path to lifecycle archive; optional. pullImages bool // Pull stack images instead of using local daemon. cloudbuild bool // Use cloudbuild network; required for Cloud Build. runtimeVersion string // A runtime version which will be applied to tests that do not explicilty set a version. runtimeName string // The name of the runtime (aka the language name such as 'go' or 'dotnet'). Used to properly set GOOGLE_RUNTIME. specialChars = regexp.MustCompile("[^a-zA-Z0-9]+") ) type requestType string // Different function signature types. const ( HTTPType requestType = "http" CloudEventType requestType = "cloudevent" BackgroundEventType requestType = "event" ) func init() { // HOME may be unset with Bazel (https://github.com/bazelbuild/bazel/issues/10652) // but `pack` requires that it should be set. if _, found := os.LookupEnv("HOME"); !found { // The Test Encyclopedia says HOME shouldbe $TEST_TMPDIR os.Setenv("HOME", os.Getenv("TEST_TMPDIR")) } } // DefineFlags sets up flags that control the behavior of the test runner. func DefineFlags() { flag.StringVar(&testData, "test-data", "", "Location of the test data files.") flag.StringVar(&structureTestConfig, "structure-test-config", "", "Location of the container structure test configuration.") flag.StringVar(&builderSource, "builder-source", "", "Location of the builder source files.") flag.StringVar(&builderImage, "builder-image", "", "Name of the builder image to test; takes precedence over builderSource.") flag.StringVar(&runImageOverride, "run-image-override", "", "Name of the run image to use during the test. This takes preference over the run-image defined in the builder.toml.") flag.StringVar(&builderPrefix, "builder-prefix", "acceptance-test-builder-", "Prefix for the generated builder image.") flag.BoolVar(&keepArtifacts, "keep-artifacts", false, "Keep images and other artifacts after tests have finished.") flag.StringVar(&packBin, "pack", "pack", "Path to pack binary.") flag.StringVar(&structureBin, "structure-test", "container-structure-test", "Path to container-structure-test.") flag.StringVar(&lifecycle, "lifecycle", "", "Location of lifecycle archive. Overrides builder.toml if specified.") flag.BoolVar(&pullImages, "pull-images", true, "Pull stack images before running the tests.") flag.BoolVar(&cloudbuild, "cloudbuild", false, "Use cloudbuild network; required for Cloud Build.") flag.StringVar(&runtimeVersion, "runtime-version", "", "A default runtime version which will be applied to the tests that do not explicitly set a version.") flag.StringVar(&runtimeName, "runtime-name", "", "The name of the runtime (aka the language name such as 'go' or 'dotnet'). Used to properly set GOOGLE_RUNTIME.") } // UnarchiveTestData extracts the test-data tgz into a temp dir and returns a cleanup function to be deferred. // This function overwrites the "test-data" to the /tmp/test-data-* directory that is created. // Call this function from TestMain if passing test-data as an archive instead of a directory. // Don't forget to call flag.Parse() first. func UnarchiveTestData() func() { tmpDir, err := ioutil.TempDir("", "test-data-") if err != nil { log.Fatalf("Creating temp directory: %v", err) } if _, err = runOutput("tar", "xzCf", tmpDir, testData); err != nil { log.Fatalf("Extracting test data archive: %v", err) } testData = tmpDir return func() { if keepArtifacts { return } if err = os.RemoveAll(tmpDir); err != nil { log.Printf("removing temp directory for test-data: %v", err) } } } // Test describes an acceptance test. type Test struct { // Name specifies the name of the application, if not provided App will be used. Name string // App specifies the path to the application in testdata. App string // Path specifies the URL path to send HTTP requests to. Path string // Env specifies build environment variables as KEY=VALUE strings. Env []string // RunEnv specifies run environment variables as KEY=VALUE strings. RunEnv []string // Entrypoint specifies the Docker image entrypoint to invoke. // All processes are added to PATH so --entrypoint=<process> will start <process>. Entrypoint string // MustMatch specifies the expected response, if not provided "PASS" will be used. MustMatch string // EnableCacheTest enables a second run of the test with the buildpacks cache enabled. EnableCacheTest bool // MustUse specifies the IDs of the buildpacks that must be used during the build. MustUse []string // MustNotUse specifies the IDs of the buildpacks that must not be used during the build. MustNotUse []string // FilesMustExist specifies names of files that must exist in the final image. FilesMustExist []string // FilesMustNotExist specifies names of files that must not exist in the final image. FilesMustNotExist []string // MustOutput specifies strings to be found in the build logs. MustOutput []string // MustNotOutput specifies strings to not be found in the build logs. MustNotOutput []string // MustOutputCached specifies strings to be found in the build logs of a cached build. MustOutputCached []string // MustNotOutputCached specifies strings to not be found in the build logs of a cached build. MustNotOutputCached []string // MustRebuildOnChange specifies a file that, when changed in Dev Mode, triggers a rebuild. MustRebuildOnChange string // MustMatchStatusCode specifies the HTTP status code hitting the function endpoint should return. MustMatchStatusCode int // FlakyBuildAttempts specifies the number of times a failing build should be retried. FlakyBuildAttempts int // RequestType specifies the payload of the request used to test the function. RequestType requestType // Map from label name to expected value. Labels map[string]string // Setup is a function that sets up the source directory before test. Setup setupFunc // VersionInclusionConstraint is a 'semver' inclusion filter for runtime versions. The FilterTest // method will only return test cases with an inclusion constrant that matches with the value of the // `-runtime-version` flag. When the inclusion constraint or `runtime-version` flag are empty all // tests are included. See semver documentation to learn what is possible. VersionInclusionConstraint string // SkipStacks is slice of buildpack stack IDs that this test case should not be run on. This is // useful for excluding apps that do not compile on the min stack. SkipStacks []string // SkipPreReleaseVersions controls execution of the specified test case for pre-released versions. // i.e. rc and nightly candidates. If true the tests are skipped for the pre-released versions SkipPreReleaseVersions bool } // SetupContext is passed into the Test.Setup function, it gives the setupFunc implementor access // to various fields to determine what modifications they should make to their source. type SetupContext struct { // SrcDir contains a path to a modifable copy of the source on local disk that will be copied // to the build environment at /workspace. SrcDir string // Builder is the name of the builder image. Builder string // RuntimeVersion is the version for which this test run will be performed. RuntimeVersion string } // ImageContext holds information about the buildpack stack images used for a test. It is returned // by ProvisionImages and must be passed as an argument to TestApp and TestBuildFailure. type ImageContext struct { // The ID of the builpack stack. StackID string // The builder image name. BuilderImage string // The run image name. RunImage string } // setupFunc is a function that is called before the test starts and can be used to modify the test source. // The setupCtx.SrcDir property contains a path to a copy of the source which can be modified before the // test runs. type setupFunc func(setupCtx SetupContext) error // builderTOML contains the values from builder.toml file type builderTOML struct { Stack struct { ID string `toml:"id"` RunImage string `toml:"run-image"` BuildImage string `toml:"build-image"` } `toml:"stack"` } // TestApp builds and a single application and verifies that it runs and handles requests. func TestApp(t *testing.T, imageCtx ImageContext, cfg Test) { t.Helper() env := prepareEnvTest(t, cfg) if cfg.Name == "" { cfg.Name = cfg.App } // Docker image names may not contain underscores or start with a capital letter. builderName, runName := imageCtx.BuilderImage, imageCtx.RunImage image := fmt.Sprintf("%s-%s", strings.ToLower(specialChars.ReplaceAllString(cfg.Name, "-")), builderName) // Delete the docker image and volumes created by pack during the build. defer func() { cleanUpVolumes(t, image) cleanUpImage(t, image) }() // Create a configuration for container-structure-tests. checks := NewStructureTest(cfg.FilesMustExist, cfg.FilesMustNotExist) // Run Setup function if provided. src := filepath.Join(testData, cfg.App) if cfg.Setup != nil { src = setupSource(t, cfg.Setup, builderName, src, cfg.App) } if cfg.EnableCacheTest { testAppWithCache(t, src, image, builderName, runName, env, checks, cfg) } else { testApp(t, src, image, builderName, runName, env, false, checks, cfg) } } func testAppWithCache(t *testing.T, src, image, builderName, runName string, env map[string]string, checks *StructureTest, cfg Test) { // Run a no-cache build, followed by a cache build t.Run("cache false", func(t *testing.T) { testApp(t, src, image, builderName, runName, env, false, checks, cfg) }) t.Run("cache true", func(t *testing.T) { testApp(t, src, image, builderName, runName, env, true, checks, cfg) }) } func testApp(t *testing.T, src, image, builderName, runName string, env map[string]string, cacheEnabled bool, checks *StructureTest, cfg Test) { buildApp(t, src, image, builderName, runName, env, cacheEnabled, cfg) verifyBuildMetadata(t, image, cfg.MustUse, cfg.MustNotUse) verifyLabelValues(t, image, cfg.Labels) verifyStructure(t, image, builderName, cacheEnabled, checks) invokeApp(t, cfg, image, cacheEnabled) } // FailureTest describes a failure test. type FailureTest struct { // Name specifies the name of the application, if not provided App will be used. Name string // App specifies the path to the application in testdata. App string // Env specifies build environment variables as KEY=VALUE strings. Env []string // MustMatch specifies a string that must appear in the builder output. MustMatch string // SkipBuilderOutputMatch is true if the MustMatch string is not expected in $BUILDER_OUTPUT. SkipBuilderOutputMatch bool // Setup is a function that sets up the source directory before test. Setup setupFunc // VersionInclusionConstraint is a 'semver' inclusion filter for runtime versions. The FilterTest // method will only return test cases with an inclusion constrant that matches with the value of the // `-runtime-version` flag. When the inclusion constraint or `runtime-version` flag are empty all // tests are included. See semver documentation to learn what is possible. VersionInclusionConstraint string // SkipPreReleaseVersions controls execution of the specified test case for pre-released versions. // i.e. rc and nightly candidates. If true the tests are skipped for the pre-released versions SkipPreReleaseVersions bool } // TestBuildFailure runs a build and ensures that it fails. Additionally, it ensures the emitted logs match mustMatch regexps. func TestBuildFailure(t *testing.T, imageCtx ImageContext, cfg FailureTest) { t.Helper() env := prepareEnvFailureTest(t, cfg) if cfg.Name == "" { cfg.Name = cfg.App } builderName, runName := imageCtx.BuilderImage, imageCtx.RunImage image := fmt.Sprintf("%s-%s", strings.ToLower(specialChars.ReplaceAllString(cfg.Name, "-")), builderName) // Delete the docker volumes created by pack during the build. if !keepArtifacts { defer cleanUpVolumes(t, image) } src := filepath.Join(testData, cfg.App) if cfg.Setup != nil { src = setupSource(t, cfg.Setup, builderName, src, cfg.App) } outb, errb, cleanup := buildFailingApp(t, src, image, builderName, runName, env) defer cleanup() r, err := regexp.Compile(cfg.MustMatch) if err != nil { t.Fatalf("regexp %q failed to compile: %v", r, err) } if r.Match(outb) { t.Logf("Expected regexp %q found in stdout.", r) } else if r.Match(errb) { t.Logf("Expected regexp %q found in stderr.", r) } else { t.Errorf("Expected regexp %q not found in stdout or stderr:\n\nstdout:\n\n%s\n\nstderr:\n\n%s", r, outb, errb) } expectedLog := "Expected pattern included in error output: true" builderOutput := string(errb) if !cfg.SkipBuilderOutputMatch && !strings.Contains(builderOutput, expectedLog) { t.Errorf("Expected regexp %q not found in BUILDER_OUTPUT", r) t.Logf("BUILDER_OUTPUT: %v", builderOutput) } } // invokeApp performs an HTTP GET or sends a Cloud Event payload to the app. func invokeApp(t *testing.T, cfg Test, image string, cache bool) { t.Helper() containerID, host, port, cleanup := startContainer(t, image, cfg.Entrypoint, cfg.RunEnv, cache) defer cleanup() // Check that the application responds with `PASS`. start := time.Now() reqType := HTTPType if cfg.RequestType != "" { reqType = cfg.RequestType } body, status, statusCode, err := sendRequest(host, port, cfg.Path, reqType) if err != nil { t.Fatalf("Unable to invoke app: %v", err) } t.Logf("Got response: status %v, body %q (in %s)", status, body, time.Since(start)) wantCode := http.StatusOK if cfg.MustMatchStatusCode != 0 { wantCode = cfg.MustMatchStatusCode } if statusCode != wantCode { t.Errorf("Unexpected status code: got %d, want %d", statusCode, wantCode) } if reqType == HTTPType && cfg.MustMatch == "" { cfg.MustMatch = "PASS" } if !strings.HasSuffix(body, cfg.MustMatch) { t.Errorf("Response body does not contain suffix: got %q, want %q", body, cfg.MustMatch) } if cfg.MustRebuildOnChange != "" { start = time.Now() // Modify a source file in the running container. if _, err := runOutput("docker", "exec", containerID, "sed", "-i", "s/PASS/UPDATED/", cfg.MustRebuildOnChange); err != nil { t.Fatalf("Unable to modify a source file in the running container %q: %v", containerID, err) } // Check that the application responds with `UPDATED`. tries := 30 for try := tries; try >= 1; try-- { time.Sleep(1 * time.Second) body, status, _, err := sendRequestWithTimeout(host, port, cfg.Path, 10*time.Second, reqType) // An app that is rebuilding can be unresponsive. if err != nil { if try == 1 { t.Fatalf("Unable to invoke app after updating source with %d attempts: %v", tries, err) } continue } want := "UPDATED" if body == want { t.Logf("Got response: status %v, body %q (in %s)", status, body, time.Since(start)) break } if try == 1 { t.Errorf("Wrong body: got %q, want %q", body, want) } } } } // sendRequest makes an http call to a given host:port/path // or send a cloud event payload to host:port if sendCloudEvents is true. // Returns the body, status and statusCode of the response. func sendRequest(host string, port int, path string, functionType requestType) (string, string, int, error) { return sendRequestWithTimeout(host, port, path, 120*time.Second, functionType) } // sendRequestWithTimeout makes an http call to a given host:port/path with the specified timeout // or send a cloud event payload with timeout to host:port if sendCloudEvents is true. // Returns the body, status and statusCode of the response. func sendRequestWithTimeout(host string, port int, path string, timeout time.Duration, functionType requestType) (string, string, int, error) { var res *http.Response var loopErr error // Try to connect the the container until it succeeds up to the timeout. sleep := 100 * time.Millisecond attempts := int(timeout / sleep) url := fmt.Sprintf("http://%s:%d%s", host, port, path) for attempt := 0; attempt < attempts; attempt++ { switch functionType { case BackgroundEventType: // GCS event example beJSON := []byte(`{ "context": { "eventId": "aaaaaa-1111-bbbb-2222-cccccccccccc", "timestamp": "2020-09-29T11:32:00.000Z", "eventType": "google.storage.object.finalize", "resource": { "service": "storage.googleapis.com", "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", "type": "storage#object" } }, "data": { "bucket": "some-bucket", "contentType": "text/plain", "crc32c": "rTVTeQ==", "etag": "CNHZkbuF/ugCEAE=", "generation": "1587627537231057", "id": "some-bucket/folder/Test.cs/1587627537231057", "kind": "storage#object", "md5Hash": "kF8MuJ5+CTJxvyhHS1xzRg==", "mediaLink": "https://www.googleapis.com/download/storage/v1/b/some-bucket/o/folder%2FTest.cs?generation=1587627537231057\u0026alt=media", "metageneration": "1", "name": "folder/Test.cs", "selfLink": "https://www.googleapis.com/storage/v1/b/some-bucket/o/folder/Test.cs", "size": "352", "storageClass": "MULTI_REGIONAL", "timeCreated": "2020-04-23T07:38:57.230Z", "timeStorageClassUpdated": "2020-04-23T07:38:57.230Z", "updated": "2020-04-23T07:38:57.230Z" } }`) res, loopErr = http.Post(url, "application/json", bytes.NewBuffer(beJSON)) case CloudEventType: ceHeaders := map[string]string{ "Content-Type": "application/cloudevents+json", } ceJSON := []byte(`{ "specversion" : "1.0", "type" : "com.example.type", "source" : "https://github.com/cloudevents/spec/pull", "subject" : "123", "id" : "A234-1234-1234", "time" : "2018-04-05T17:31:00Z", "comexampleextension1" : "value", "data" : "hello" }`) req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(ceJSON)) if err != nil { return "", "", 0, fmt.Errorf("error creating CloudEvent HTTP request: %w", loopErr) } for k, v := range ceHeaders { req.Header.Add(k, v) } client := &http.Client{} res, loopErr = client.Do(req) default: res, loopErr = http.Get(url) } if loopErr == nil { break } time.Sleep(sleep) } // The connection never succeeded. if loopErr != nil { return "", "", 0, fmt.Errorf("error making request: %w", loopErr) } // The connection was a success. bytes, err := ioutil.ReadAll(res.Body) if err != nil { return "", "", 0, fmt.Errorf("error reading body: %w", err) } res.Body.Close() return strings.TrimSpace(string(bytes)), res.Status, res.StatusCode, nil } // randString generates a random string of length n. func randString(n int) string { rand.Seed(time.Now().UTC().UnixNano()) b := make([]byte, n) for i := range b { b[i] = 'a' + byte(rand.Intn(26)) } return string(b) } // runOutput runs the given command and returns its stdout or an error. func runOutput(args ...string) (string, error) { log.Printf("Running %v\n", args) start := time.Now() cmd := exec.Command(args[0], args[1:]...) out, err := cmd.Output() if err != nil { logs := "" if ee, ok := err.(*exec.ExitError); ok { logs = fmt.Sprintf("\nstdout:\n%s\nstderr:\n%s\n", out, ee.Stderr) } return "", fmt.Errorf("running command %v: %v%s", args, err, logs) } log.Printf("Finished %v (in %s)\n", args, time.Since(start)) return strings.TrimSpace(string(out)), nil } func runCombinedOutput(args ...string) (string, error) { log.Printf("Running %v\n", args) start := time.Now() cmd := exec.Command(args[0], args[1:]...) out, err := cmd.CombinedOutput() if err != nil { logs := fmt.Sprintf("\nstdout & stderr:\n%s\n", out) return "", fmt.Errorf("running command %v: %v%s", args, err, logs) } log.Printf("Finished %v (in %s)\n", args, time.Since(start)) return string(out), nil } // runDockerLogs returns the logs for a container, the lineLimit parameter // controls the maximum number of lines read from the log func runDockerLogs(containerID string, lineLimit int) (string, error) { return runCombinedOutput("docker", "logs", "--tail", string(lineLimit), containerID) } // cleanUpImage attempts to delete an image from the Docker daemon. func cleanUpImage(t *testing.T, name string) { t.Helper() if keepArtifacts { return } if _, err := runOutput("docker", "rmi", "-f", name); err != nil { t.Logf("Failed to clean up image: %v", err) } } // ProvisionImages provisions the builder, build, and run images necessary for running // a test. // // The 'builderName' return value is the name of the builder image. // The 'runName' return value is the name of the run image. This value will be the // empty string when the run image is not override and the builder's default run // image is to be used. // // The 'cleanup' return value is a function which should be run after the tests are // complete to clean up the images which are explicitly created. Images that are // pulled are not cleaned up to prevent conflicts with other tests. func ProvisionImages(t *testing.T) (ImageContext, func()) { t.Helper() if err := checktools.Installed(); err != nil { t.Fatalf("Error checking tools: %v", err) } if err := checktools.PackVersion(); err != nil { t.Fatalf("Error checking pack version: %v", err) } builderName := generateRandomImageName(builderPrefix) if builderImage != "" { t.Logf("Testing existing builder image: %s", builderImage) if pullImages { if _, err := runOutput("docker", "pull", builderImage); err != nil { t.Fatalf("Error pulling %s: %v", builderImage, err) } } // Pack cache is based on builder name; retag with a unique name. if _, err := runOutput("docker", "tag", builderImage, builderName); err != nil { t.Fatalf("Error tagging %s as %s: %v", builderImage, builderName, err) } runName, cleanUpRun, err := provisionRunImageFromBuilder(builderName) if err != nil { t.Fatalf("Error provisioning run image for builder %q: %v", builderName, err) } stackID, err := getImageStackID(builderName) if err != nil { t.Fatalf("Getting stack ID from builder %q: %v", builderName, err) } imageCtx := ImageContext{ StackID: stackID, BuilderImage: builderName, RunImage: runName, } return imageCtx, func() { cleanUpImage(t, builderName) cleanUpRun(t) } } builderLoc, cleanUpBuilder := extractBuilder(t, builderSource) config := filepath.Join(builderLoc, "builder.toml") if lifecycle != "" { t.Logf("Using lifecycle location: %s", lifecycle) if c, err := updateLifecycle(config, lifecycle); err != nil { t.Fatalf("Error updating lifecycle location: %v", err) } else { config = c } } builderConfig, err := readBuilderTOML(config) if err != nil { t.Fatalf("Error reading builder.toml: %v", err) } // Pull images once in the beginning to prevent them from changing in the middle of testing. // The images are intentionally not cleaned up to prevent conflicts across different test targets. if pullImages { buildName := builderConfig.Stack.BuildImage if _, err := runOutput("docker", "pull", buildName); err != nil { t.Fatalf("Error pulling %s: %v", buildName, err) } } runName, cleanUpRun, err := provisionRunImageFromTOML(builderConfig) if err != nil { t.Fatalf("Error provisioning run image: %v", err) } // Pack command to create the builder. args := strings.Fields(fmt.Sprintf("builder create %s --config %s --pull-policy never --verbose --no-color", builderName, config)) cmd := exec.Command(packBin, args...) outFile, errFile, cleanup := outFiles(t, builderName, "pack", "create-builder") defer cleanup() var outb, errb bytes.Buffer cmd.Stdout = io.MultiWriter(outFile, &outb) // pack emits some errors to stdout. cmd.Stderr = io.MultiWriter(errFile, &errb) // pack emits buildpack output to stderr. start := time.Now() t.Logf("Creating builder (logs %s)", filepath.Dir(outFile.Name())) if err := cmd.Run(); err != nil { t.Fatalf("Error creating builder: %v, logs:\nstdout: %s\nstderr:%s", err, outb.String(), errb.String()) } t.Logf("Successfully created builder: %s (in %s)", builderName, time.Since(start)) imageCtx := ImageContext{ StackID: builderConfig.Stack.ID, BuilderImage: builderName, RunImage: runName, } return imageCtx, func() { cleanUpImage(t, builderName) cleanUpBuilder() cleanUpRun(t) } } func provisionRunImageFromTOML(builderConfig *builderTOML) (string, func(t *testing.T), error) { runName := builderConfig.Stack.RunImage if runImageOverride != "" { runName = runImageOverride } if pullImages { if _, err := runOutput("docker", "pull", runName); err != nil { return "", nil, fmt.Errorf("pulling %q: %w", runName, err) } } if runName == builderConfig.Stack.RunImage { // when the run image name is the one defined in the builderconfig, do not verify the stack ids // match because a builder.toml should contain valid configuration. return runName, func(t *testing.T) {}, nil } return provisionImageWithMatchingStackID(runName, builderConfig.Stack.ID) } func provisionRunImageFromBuilder(builderName string) (string, func(t *testing.T), error) { builderDefinedRunImage, err := runImageFromMetadata(builderName) if err != nil { return "", nil, fmt.Errorf("Error extracting run image from image %q: %w", builderName, err) } runName := builderDefinedRunImage if runImageOverride != "" { runName = runImageOverride } if pullImages { if _, err := runOutput("docker", "pull", runName); err != nil { return "", nil, fmt.Errorf("pulling %q: %w", runName, err) } } if builderDefinedRunImage == runName { // when the run image is the one defined for the builder, do not verify the stack ids match // because the builder should contain valid configuration. return runName, func(t *testing.T) {}, nil } builderStackID, err := getImageStackID(builderName) if err != nil { return "", nil, fmt.Errorf("getting stack id of builder %q: %w", builderName, err) } return provisionImageWithMatchingStackID(runName, builderStackID) } // provisionImageWithMatchingStackId returns an image with the contents of 'fromImage' and a stack // ID of 'stackID'. The second return value is a cleanUp function which will destroy the returned // image if the image was newly created. The cleanUp function is a no-op if the fromImage already // had the desired stackID. // // This function is useful for ensuring a run image has the same stack id as the builder. This is // necessary because pack requires that the two match. func provisionImageWithMatchingStackID(fromImage, stackID string) (string, func(t *testing.T), error) { imageStackID, err := getImageStackID(fromImage) if err != nil { return "", nil, fmt.Errorf("getting stack id of image %q: %w", fromImage, err) } if imageStackID == stackID { return fromImage, func(t *testing.T) {}, nil } newImage, err := newImageWithStackID(fromImage, stackID) if err != nil { return "", nil, fmt.Errorf("creating image from %q with stack id %q: %w", fromImage, stackID, err) } cleanUp := func(t *testing.T) { cleanUpImage(t, newImage) } return newImage, cleanUp, nil } func getImageStackID(image string) (string, error) { out, err := runOutput("docker", "inspect", `--format={{index .Config.Labels "io.buildpacks.stack.id"}}`, image) if err != nil { return "", fmt.Errorf("getting stack id from docker inspect: %w", err) } return out, nil } func newImageWithStackID(fromImage, stackID string) (string, error) { newImage := generateRandomImageName(fromImage) _, err := runCombinedOutput("bash", "-c", fmt.Sprintf(`echo "FROM %s" | docker build --label io.buildpacks.stack.id="%s" -t "%s" -`, fromImage, stackID, newImage)) if err != nil { return "", fmt.Errorf("changing stack id label on %q: %v", fromImage, err) } return newImage, nil } func generateRandomImageName(baseName string) string { rand := randString(10) if strings.Contains(baseName, ":") { return fmt.Sprintf("%v_%v", baseName, rand) } return baseName + rand } func extractBuilder(t *testing.T, builderSource string) (string, func()) { t.Helper() if !strings.HasSuffix(builderSource, ".tar") { return builderSource, func() {} } start := time.Now() d, err := ioutil.TempDir("", "builder-") if err != nil { t.Fatalf("Error creating temp builder location: %v", err) } out, err := runOutput("tar", "xCf", d, builderSource) if err != nil { t.Fatalf("Error extracting %s: %s\n%s\n", builderSource, err, out) } t.Logf("Successfully extracted builder to %s (in %s)", d, time.Since(start)) return d, func() { if !keepArtifacts { os.RemoveAll(d) } } } // updateLifecycle rewrites the lifecycle field of the config to the given uri. func updateLifecycle(config, uri string) (string, error) { p, err := ioutil.ReadFile(config) if err != nil { return "", fmt.Errorf("reading %s: %v", config, err) } var data map[string]interface{} if err := toml.Unmarshal(p, &data); err != nil { return "", fmt.Errorf("unmarshaling %s: %v", config, err) } data["lifecycle"] = map[string]string{ "uri": uri, } f, err := ioutil.TempFile("", "builder-*.toml") if err != nil { return "", fmt.Errorf("creating temporary file: %v", err) } defer f.Close() if err := toml.NewEncoder(f).Encode(data); err != nil { return "", fmt.Errorf("writing data: %v", err) } // Buildpack paths are relative; move to original directory. orig := f.Name() dest := filepath.Join(filepath.Dir(config), filepath.Base(orig)) if err := os.Rename(orig, dest); err != nil { return "", fmt.Errorf("renaming %s to %s: %v", orig, dest, err) } return dest, nil } // runImageFromMetadata returns the run image name from the metadata of the given image. func runImageFromMetadata(image string) (string, error) { format := "--format={{(index (index .Config.Labels) \"io.buildpacks.builder.metadata\")}}" out, err := runOutput("docker", "inspect", image, format) if err != nil { return "", fmt.Errorf("reading builder metadata: %v", err) } var metadata struct { Stack struct { RunImage struct { Image string `json:"image"` } `json:"runImage"` } `json:"stack"` } if err := json.Unmarshal([]byte(out), &metadata); err != nil { return "", fmt.Errorf("error unmarshalling build metadata: %v", err) } return metadata.Stack.RunImage.Image, nil } // setupSource runs the given setup function to set up the source directory before a test. func setupSource(t *testing.T, setup setupFunc, builder, src, app string) string { t.Helper() root := "" // Cloud Build runs in docker-in-docker mode where directories are mounted from the host daemon. // Therefore, we need to put the temporary directory in the shared /workspace volume. if cloudbuild { root = "/workspace" } temp, err := ioutil.TempDir(root, path.Base(app)) if err != nil { t.Fatalf("Error creating temporary directory: %v", err) } t.Cleanup(func() { os.RemoveAll(temp) }) sep := string(filepath.Separator) if _, err := runOutput("cp", "-R", src+sep+".", temp); err != nil { t.Fatalf("Error copying app files: %v", err) } setupCtx := SetupContext{ SrcDir: temp, Builder: builder, RuntimeVersion: runtimeVersion, } if err := setup(setupCtx); err != nil { t.Fatalf("Error running test setup: %v", err) } return temp } func readBuilderTOML(path string) (*builderTOML, error) { bytes, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading %q: %w", path, err) } var bc builderTOML if err := toml.Unmarshal(bytes, &bc); err != nil { return nil, fmt.Errorf("unmarshalling %q: %w", path, err) } return &bc, nil } func buildCommand(srcDir, image, builderName, runName string, env map[string]string, cache bool) []string { // Pack command to build app. args := strings.Fields(fmt.Sprintf("%s build %s --builder %s --path %s --pull-policy never --verbose --no-color --trust-builder %s", packBin, image, builderName, srcDir, cacheOptions(image))) if runName != "" { args = append(args, "--run-image", runName) if hasRuntimePreinstalled(runName) { // This skips adding the language runtime downloaded during the build to the final // image as a launch layer. For non-generic run images, the language runtime is already // included in the base image. args = append(args, "--env", "X_GOOGLE_SKIP_RUNTIME_LAUNCH=true") } } if !cache { args = append(args, "--clear-cache") } for k, v := range env { args = append(args, "--env", fmt.Sprintf("%s=%s", k, v)) } // Prevents a race condition in pack when concurrently running builds with the same builder. // Pack generates an "emphemeral builder" that contains env vars, adding an env var with a random // value ensures that the generated builder sha is unique and removing it after one build will // not affect other builds running concurrently. args = append(args, "--env", "GOOGLE_RANDOM="+randString(8), "--env", "GOOGLE_DEBUG=true") args = append(args, "--network", "host") log.Printf("Running %v\n", args) return args } func cacheOptions(image string) string { buildVolume, launchVolume := volumeNames(image) return fmt.Sprintf("--cache type=build;format=volume;name=%s --cache type=launch;format=volume;name=%s", buildVolume, launchVolume) } // hasRuntimePreinstalled returns whether or not the image is the "generic" run image that does not // contain the language runtime built in. For containers built on the generic run image, the // language runtime is added dynamically during the build instead. The OSS builder and GAE Flex // build on the generic run images. GCF and GAE standard use language-specific run images to allow // the language runtime to be updated during automatic base image updates. func hasRuntimePreinstalled(runName string) bool { // Generic run image example (should NOT match): // gcr.io/gae-runtimes/buildpacks/google-gae-22/nodejs/run // gcr.io/gae-runtimes/stacks/google-gae-18/run // gcr.io/buildpacks/google-18/run // // Non-generic run image example (should match): // gcr.io/gae-runtimes/buildpacks/nodejs14/run // gcr.io/${PROJECT}/buildpacks/${RUNTIME}/run re := regexp.MustCompile(`/buildpacks/(?:go|nodejs|dotnet|java|php|ruby|python)\d+/run`) return re.MatchString(runName) } // buildApp builds an application image from source. func buildApp(t *testing.T, srcDir, image, builderName, runName string, env map[string]string, cache bool, cfg Test) { t.Helper() attempts := cfg.FlakyBuildAttempts if attempts < 1 { attempts = 1 } start := time.Now() var outb, errb bytes.Buffer for attempt := 1; attempt <= attempts; attempt++ { filename := fmt.Sprintf("%s-cache-%t", image, cache) if attempt > 1 { filename = fmt.Sprintf("%s-attempt-%d", filename, attempt) } outFile, errFile, cleanup := outFiles(t, builderName, "pack-build", filename) defer cleanup() bcmd := buildCommand(srcDir, image, builderName, runName, env, cache) cmd := exec.Command(bcmd[0], bcmd[1:]...) cmd.Stdout = io.MultiWriter(outFile, &outb) // pack emits detect output to stdout. cmd.Stderr = io.MultiWriter(errFile, &errb) // pack emits build output to stderr. t.Logf("Building application %s (logs %s)", image, filepath.Dir(outFile.Name())) if err := cmd.Run(); err != nil { if attempt < attempts { t.Logf("Error building application %s, attempt %d of %d: %v, logs:\n%s\n%s", image, attempt, attempts, err, outb.String(), errb.String()) outb.Reset() errb.Reset() } else { t.Fatalf("Error building application %s: %v, logs:\n%s\n%s", image, err, outb.String(), errb.String()) } } else { // The application built successfully. break } } // Check that expected output is found in the logs. mustOutput := cfg.MustOutput mustNotOutput := cfg.MustNotOutput if cache { mustOutput = cfg.MustOutputCached mustNotOutput = cfg.MustNotOutputCached } for _, text := range mustOutput { if !strings.Contains(errb.String(), text) { t.Errorf("Build logs must contain %q:\n%s", text, errb.String()) } } for _, text := range mustNotOutput { if strings.Contains(errb.String(), text) { t.Errorf("Build logs must not contain %q:\n%s", text, errb.String()) } } // Scan for incorrect cache hits/misses. if cache { if strings.Contains(errb.String(), cacheMissMessage) { t.Fatalf("FAIL: Cached build had a cache miss:\n%s", errb.String()) } } else { if strings.Contains(errb.String(), cacheHitMessage) { t.Fatalf("FAIL: Non-cache build had a cache hit:\n%s", errb.String()) } } t.Logf("Successfully built application: %s (in %s)", image, time.Since(start)) } // buildFailingApp attempts to build an app and ensures that it failues (non-zero exit code). // It returns the build's stdout, stderr and a cleanup function. func buildFailingApp(t *testing.T, srcDir, image, builderName, runName string, env map[string]string) ([]byte, []byte, func()) { t.Helper() bcmd := buildCommand(srcDir, image, builderName, runName, env, false) cmd := exec.Command(bcmd[0], bcmd[1:]...) outFile, errFile, cleanup := outFiles(t, builderName, "pack-build-failing", image) defer cleanup() var outb, errb bytes.Buffer cmd.Stdout = io.MultiWriter(outFile, &outb) cmd.Stderr = io.MultiWriter(errFile, &errb) t.Logf("Building application expected to fail (logs %s)", filepath.Dir(outFile.Name())) if err := cmd.Run(); err == nil { // No error, but we expected one; this is a test failure. t.Fatal("Application built successfully, but should not have.") } else { // We got an error, but we need to check that it's due to a non-zero exit code (which is what // we want in this case). If the error is an ExitError, it was a non-zero exit code. // Otherwise, it a truly unexpected error. if _, ok := err.(*exec.ExitError); !ok { t.Fatalf("executing command %q: %v", bcmd, err) } else { t.Logf("Application build failed as expected: %s", image) } } return outb.Bytes(), errb.Bytes(), func() { cleanUpImage(t, image) } } // verifyStructure verifies the structure of the image. func verifyStructure(t *testing.T, image, builder string, cache bool, checks *StructureTest) { t.Helper() start := time.Now() configurations := []string{structureTestConfig} if checks != nil { tmpDir, err := ioutil.TempDir("", "container-structure-tests") if err != nil { t.Fatalf("Error creating temp directory: %v", err) } defer func() { if keepArtifacts { return } if err = os.RemoveAll(tmpDir); err != nil { log.Printf("Removing temp directory for container-structure-tests: %v", err) } }() // Create a config file for container-structure-tests. buf, err := json.Marshal(checks) if err != nil { t.Fatalf("Marshalling container-structure-tests configuration: %v", err) } configPath := filepath.Join(tmpDir, "config.json") err = ioutil.WriteFile(configPath, buf, 0644) if err != nil { t.Fatalf("Writing container-structure-tests configuration: %v", err) } configurations = append(configurations, configPath) } // Container-structure-test command to test the image. args := []string{"test", "--image", image} for _, configuration := range configurations { args = append(args, "--config", configuration) } cmd := exec.Command(structureBin, args...) outFile, errFile, cleanup := outFiles(t, builder, "container-structure-test", fmt.Sprintf("%s-cache-%t", image, cache)) defer cleanup() var outb bytes.Buffer cmd.Stdout = io.MultiWriter(outFile, &outb) cmd.Stderr = errFile t.Logf("Running structure tests (logs %s)", filepath.Dir(outFile.Name())) if err := cmd.Run(); err != nil { t.Fatalf("Error running structure tests: %v, logs:\n%s", err, outb.String()) } t.Logf("Successfully ran structure tests on %s (in %s)", image, time.Since(start)) } func verifyLabelValues(t *testing.T, image string, labels map[string]string) { t.Helper() start := time.Now() for label, value := range labels { out, err := runOutput("docker", "inspect", fmt.Sprintf("--format={{index .Config.Labels %q}}", label), image) if err != nil { t.Errorf("Error reading label %v: %v", label, err) } else if out != value { t.Errorf("Unexpected value for label %v\ngot: %v\nwant %v", label, out, value) } } t.Logf("Finished verifying label values (in %s)", time.Since(start)) } // verifyBuildMetadata verifies the image was built with correct buildpacks. func verifyBuildMetadata(t *testing.T, image string, mustUse, mustNotUse []string) { t.Helper() start := time.Now() out, err := runOutput("docker", "inspect", "--format={{index .Config.Labels \"io.buildpacks.build.metadata\"}}", image) if err != nil { t.Fatalf("Error reading build metadata: %v", err) } var metadata struct { Buildpacks []struct { ID string `json:"id"` } `json:"buildpacks"` } if err := json.Unmarshal([]byte(out), &metadata); err != nil { t.Fatalf("Error unmarshalling build metadata: %v", err) } usedBuildpacks := map[string]bool{} for _, bp := range metadata.Buildpacks { usedBuildpacks[bp.ID] = true } for _, id := range mustUse { if _, used := usedBuildpacks[id]; !used { t.Errorf("Must use buildpack %s was not used.", id) } } for _, id := range mustNotUse { if _, used := usedBuildpacks[id]; used { t.Errorf("Must not use buildpack %s was used.", id) } } t.Logf("Finished verifying build metadata (in %s)", time.Since(start)) } // startContainer starts a container for the given app // The function returns the containerID, the host and port at which the app is reachable and a cleanup function. func startContainer(t *testing.T, image, entrypoint string, env []string, cache bool) (string, string, int, func()) { t.Helper() containerName := xid.New().String() command := []string{"docker", "run", "--detach", fmt.Sprintf("--name=%s", containerName)} for _, e := range env { command = append(command, "--env", e) } if cloudbuild { command = append(command, "--network=cloudbuild") } else { command = append(command, "--publish=8080") } if entrypoint != "" { command = append(command, "--entrypoint="+entrypoint) } command = append(command, image) id, err := runOutput(command...) if err != nil { t.Fatalf("Error starting container: %v", err) } t.Logf("Successfully started container: %s", id) host, port := getHostAndPortForApp(t, id, containerName) return id, host, port, func() { if _, err := runOutput("docker", "stop", id); err != nil { t.Logf("Failed to stop container: %v", err) } if t.Failed() { // output the container logs when a test failed, this can be useful for debugging failures in the test application outputDockerLogs(t, id) } if keepArtifacts { return } if _, err := runOutput("docker", "rm", "-f", id); err != nil { t.Logf("Failed to clean up container: %v", err) } } } func outputDockerLogs(t *testing.T, containerID string) { out, err := runDockerLogs(containerID, 1000) if err == nil { t.Logf("docker logs %v:\n%v", containerID, out) } else { t.Errorf("error fetching docker logs for container %v: %v", containerID, err) } } func getHostAndPortForApp(t *testing.T, containerID, containerName string) (string, int) { if cloudbuild { // In cloudbuild, the host environment is also a docker container and shares the same // network as the containers launched. In docker, within a network, the 'name' of a // container is also a hostname and can be used to address the container. This // useful for making sure http requests go to the intended application rather than // addressing by IP Address which, in the case of a terminated container, can lead to // addressing another newly started container which used the IP address of the // terminated container. // // Remapping random local ports to the container ports, like we do for the local test // runs, is not an option in cloudbuild because the build is run in a host docker // container and the launched containers are not "in" the host container and // therefore ports are not mapped at the build container's 'localhost'. return containerName, 8080 } if v := os.Getenv("DOCKER_IP_UBUNTU"); v != "" { return v, hostPort(t, containerID) } // When supplying the publish parameter with no local port picked, docker will // choose a random port to map to the published port. In this case, it means // there will be a mapping from localhost:${RAND_PORT} -> container:8080. There is a // small chance of a port collision. This can happen when we start a container and it // has a mapping from localhost:p1 -> container:8080, at this point the container // could terminate (example: app fails to start), another test could start a container // and docker could assign it the same random port 'p1'. The alternative is to write // our own port picker logic which would bring its own set of issues when run on // glinux machines which have various services running on ports. Since we have not // observed issues with the docker port picker, we are continuing to use it. return "localhost", hostPort(t, containerID) } // hostPort returns the host port assigned to the exposed container port. func hostPort(t *testing.T, id string) int { t.Helper() format := "--format={{(index (index .NetworkSettings.Ports \"8080/tcp\") 0).HostPort}}" portstr, err := runOutput("docker", "inspect", id, format) if err != nil { t.Fatalf("Error getting port: %v", err) } port, err := strconv.Atoi(portstr) if err != nil { t.Fatalf("Error converting port to int: %v", err) } t.Logf("Successfully got port: %d", port) return port } func outFiles(t *testing.T, builder, dir, logName string) (outFile, errFile *os.File, cleanup func()) { t.Helper() tempDir := os.TempDir() if blaze := os.Getenv("TEST_UNDECLARED_OUTPUTS_DIR"); blaze != "" { tempDir = blaze } d := filepath.Join(tempDir, "buildpack-acceptance-logs", builder, dir) if err := os.MkdirAll(d, 0755); err != nil { t.Fatalf("Failed to create logs dir %q: %v", d, err) } logName = strings.ReplaceAll(logName, "/", "_") outName := filepath.Join(d, fmt.Sprintf("%s.stdout", logName)) errName := filepath.Join(d, fmt.Sprintf("%s.stderr", logName)) outFile, err := os.Create(outName) if err != nil { t.Fatalf("Error creating stdout file %q: %v", outName, err) } errFile, err = os.Create(errName) if err != nil { t.Fatalf("Error creating stderr file %q: %v", errName, err) } return outFile, errFile, func() { if err := outFile.Close(); err != nil { t.Fatalf("failed to close %q: %v", outFile.Name(), err) } if err := errFile.Close(); err != nil { t.Fatalf("failed to close %q: %v", outFile.Name(), err) } } } // cleanUpVolumes tries to delete volumes created by pack during the build. func cleanUpVolumes(t *testing.T, image string) { t.Helper() if keepArtifacts { return } buildVolume, launchVolume := volumeNames(image) if _, err := runOutput("docker", "volume", "rm", "-f", launchVolume, buildVolume); err != nil { t.Logf("Failed to clean up cache volumes: %v", err) } } func volumeNames(image string) (string, string) { // This logic is copied from pack's codebase. See: // https://github.com/buildpacks/pack/blob/92bc87b297695e4ac6baf559bad2efd55aecec1f/internal/paths/paths.go#L81 reservedNameConversions := map[string]string{ "aux": "a_u_x", "com": "c_o_m", "con": "c_o_n", "lpt": "l_p_t", "nul": "n_u_l", "prn": "p_r_n", ":": "_", } for k, v := range reservedNameConversions { image = strings.ReplaceAll(image, k, v) } return image + ".build", image + ".launch" } // PullImages returns the value of the -pull-images flag. func PullImages() bool { return pullImages } // FilterTests returns a new slice with only tests that should be run. Tests are filtered out if // their VersionInclusionConstraint does not match the `-runtime-version` flag. func FilterTests(t *testing.T, imageCtx ImageContext, testCases []Test) []Test { results := make([]Test, 0) for _, tc := range testCases { if tc.SkipPreReleaseVersions && isPreReleaseVersion() { continue } if ShouldTestVersion(t, tc.VersionInclusionConstraint) && ShouldTestStack(t, imageCtx.StackID, tc.SkipStacks) { results = append(results, tc) } } return results } // FilterFailureTests returns a new slice with only tests that should be run. Tests are filtered out // if their VersionInclusionConstraint does not match the `-runtime-version` flag. func FilterFailureTests(t *testing.T, testCases []FailureTest) []FailureTest { results := make([]FailureTest, 0) for _, tc := range testCases { if tc.SkipPreReleaseVersions && isPreReleaseVersion() { continue } if ShouldTestVersion(t, tc.VersionInclusionConstraint) { results = append(results, tc) } } return results } // isPreReleaseVersion returns true if the runtime version is a pre-release version. func isPreReleaseVersion() bool { return strings.Contains(runtimeVersion, "rc") || strings.Contains(runtimeVersion, "nightly") || strings.Contains(runtimeVersion, "RC") } // ShouldTestStack returns true if the current test should be included on test runs using the given // buildpack stack. func ShouldTestStack(t *testing.T, stackID string, skipStacks []string) bool { t.Helper() for _, skipStack := range skipStacks { if skipStack == stackID { return false } } return true } // ShouldTestVersion returns true if the current test run's version is included // in the constraint parameter. An empty inclusion constraint is treated as // matching all versions. // // The version comparison check supports partial matches. For example, an excluded // version of '12.5' will match all '12.5.x' versions. In addition, you can specify // ranges such as '>=10.0.0'. The version comparison uses semver2 for the constraint // comparision. See the documentation for semver2 to learn more. func ShouldTestVersion(t *testing.T, inclusionConstraint string) bool { t.Helper() v := runtimeVersion if v == "" || inclusionConstraint == "" { return true } // The format of Go pre-release version e.g. 1.20rc1 doesn't follow the semver rule // that requires a hyphen before the identifier "rc". if strings.Contains(v, "rc") && !strings.Contains(v, "-rc") { v = strings.Replace(v, "rc", "-rc", 1) } if strings.Contains(v, "RC") && !strings.Contains(v, "-RC") { v = strings.Replace(v, "RC", "-RC", 1) } rtVer, err := semver.NewVersion(v) if err != nil { t.Fatalf("Unable to use %q as a semver.Version: %v", v, err) } return versionMatches(t, rtVer, inclusionConstraint) } func versionMatches(t *testing.T, version *semver.Version, constraint string) bool { t.Helper() c, err := semver.NewConstraint(constraint) if err != nil { t.Fatalf("Unable to use %q as a semver.Constraint: %v", constraint, err) } return c.Check(version) } func sliceContains(value string, slice []string) bool { for _, v := range slice { if v == value { return true } } return false } func getNewVersions(runtime string) []string { // Check if JSON file exists. if _, err := os.Stat("new_versions.json"); err != nil { return nil } versionMap := make(map[string][]string) file, err := ioutil.ReadFile("new_versions.json") if err != nil { log.Fatalf("Error parsing JSON file: %q", err) } if err = json.Unmarshal(file, &versionMap); err != nil { log.Fatalf("Unable to decode JSON version map: %q", err) } if _, ok := versionMap[runtime]; !ok { return nil } return versionMap[runtime] }